521 lines
30 KiB
JavaScript
521 lines
30 KiB
JavaScript
const { installCountFrames, countText } = require('./daily-count-test-utils.cjs');
|
|
const { animationModule, contentModule } = require('./popup-animation-test-support.cjs');
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { stripTypeScriptTypes } = require('node:module');
|
|
|
|
const root = path.resolve(__dirname, '..');
|
|
const prefab = JSON.parse(fs.readFileSync(path.join(root, 'assets/action_bundle/prefab/dailyQuests.prefab'), 'utf8'));
|
|
const source = fs.readFileSync(path.join(root, 'assets/action_bundle/script/DailyQuests.ts'), 'utf8')
|
|
.replace(/^import .*;\r?$/gm, '').replace(/^\s*@(?:ccclass|property[^\r\n]*)\r?$/gm, '')
|
|
.replace('export default class DailyQuests', 'class DailyQuests');
|
|
const trackerSource = fs.readFileSync(path.join(root, 'assets/Script/module/Task/DailyTaskProgressTracker.ts'), 'utf8')
|
|
.replace(/^export type /gm, 'type ').replace('export default class', 'class');
|
|
const code = stripTypeScriptTypes(trackerSource + '\n' + source) + '\nglobalThis.DailyQuests = DailyQuests;';
|
|
|
|
class Node {
|
|
constructor(data) {
|
|
if (typeof data === 'string') data = {_name:data,_active:true,_opacity:255,_contentSize:{width:0,height:0},_trs:{array:[0,0,0,0,0,0,1,1,1]}};
|
|
this.name = data._name; this.active = data._active; this.opacity = data._opacity;
|
|
this.width = data._contentSize.width; this.height = data._contentSize.height;
|
|
this.x = data._trs.array[0]; this.y = data._trs.array[1];
|
|
this.scaleX = data._trs.array[7]; this.scaleY = data._trs.array[8];
|
|
this.children = []; this.components = {}; this.listeners = [];
|
|
}
|
|
set scale(value) { this.scaleX = this.scaleY = value; }
|
|
get scale() { return this.scaleX; }
|
|
get activeInHierarchy() { return !this.destroyed && this.active && (!this.parent || this.parent.activeInHierarchy); }
|
|
getChildByName(name) { return this.children.find(child => child.name === name); }
|
|
addComponent(type) { return this.components[String(type)] = {}; }
|
|
getComponent(type) { return this.components[String(type)] || null; }
|
|
on(type, handler, target, capture) { this.listeners.push({ type, handler, target, capture }); }
|
|
off(type, handler) { this.listeners = this.listeners.filter(listener => listener.type !== type || listener.handler !== handler); }
|
|
addChild(child) { child.removeFromParent(); child.parent = this; this.children.push(child); }
|
|
removeFromParent() { if (this.parent) this.parent.children = this.parent.children.filter(child => child !== this); this.parent = null; }
|
|
destroy() { this.destroyed = true; this.removeFromParent(); }
|
|
setSiblingIndex() {}
|
|
setPosition(x, y) { this.x = x; this.y = y; }
|
|
setScale(x, y = x) { this.scaleX = x; this.scaleY = y; }
|
|
setContentSize(width, height) { this.width = width; this.height = height; }
|
|
}
|
|
Node.EventType = { TOUCH_START: 'touchstart' };
|
|
|
|
function installComponent(node, component) {
|
|
const copy = { ...component };
|
|
if (copy.clickEvents) copy.clickEvents = copy.clickEvents.map(event => ({ ...event }));
|
|
if (copy.__type__ === 'cc.Layout') copy.updateLayout = () => {};
|
|
if (copy.__type__ === 'cc.Widget') {
|
|
copy.enabled = component.enabled ?? component._enabled;
|
|
copy.alignments = 0;
|
|
copy.updateAlignment = () => { copy.alignments++; };
|
|
}
|
|
node.components[copy.__type__] = copy;
|
|
}
|
|
function build(id) {
|
|
const data = prefab[id], node = new Node(data);
|
|
for (const ref of data._components) {
|
|
const component = structuredClone(prefab[ref.__id__]);
|
|
if (component.clickEvents) component.clickEvents = component.clickEvents.map(event => event.__id__ == null ? event : structuredClone(prefab[event.__id__]));
|
|
installComponent(node, component);
|
|
}
|
|
for (const ref of data._children) node.addChild(build(ref.__id__));
|
|
return node;
|
|
}
|
|
function clone(node) {
|
|
const copy = Object.assign(Object.create(Node.prototype), node, { parent: null, children: [], components: {}, listeners: [] });
|
|
for (const component of Object.values(node.components)) installComponent(copy, component);
|
|
for (const child of node.children) copy.addChild(clone(child));
|
|
return copy;
|
|
}
|
|
|
|
function setup(states = [0, 1, 1, 2], options = {}) {
|
|
const tweens = [], requests = [], timers = [], grants = [], flights = [];
|
|
const storage = new Map();
|
|
// Advance real durations without waiting on a wall clock; callbacks run only
|
|
// after their preceding segment, and stopped tweens cannot change nodes.
|
|
class Tween {
|
|
constructor(target) { this.target = target; this.steps = []; this.index = 0; this.elapsed = 0; this.cancelled = false; this.completed = false; }
|
|
to(duration, properties, easing) { this.steps.push({ duration, properties, easing }); return this; }
|
|
delay(duration) { this.steps.push({ duration }); return this; }
|
|
call(callback) { this.steps.push({ callback }); return this; }
|
|
start() { tweens.push(this); return this; }
|
|
stop() { this.cancelled = true; return this; }
|
|
advance(seconds) {
|
|
if (this.cancelled || this.completed) return;
|
|
while (this.index < this.steps.length && !this.cancelled) {
|
|
const step = this.steps[this.index];
|
|
if (step.callback) { this.index++; step.callback(); continue; }
|
|
if (!this.initial) this.initial = Object.fromEntries(Object.keys(step.properties || {}).map(key => [key, this.target[key]]));
|
|
const consumed = Math.min(seconds, step.duration - this.elapsed);
|
|
this.elapsed += consumed; seconds -= consumed;
|
|
const progress = Math.min(1, this.elapsed / step.duration);
|
|
for (const [key, end] of Object.entries(step.properties || {})) this.target[key] = this.initial[key] + (end - this.initial[key]) * progress;
|
|
if (step.easing?.onUpdate) step.easing.onUpdate(this.target, progress);
|
|
if (this.elapsed + 1e-9 < step.duration) return;
|
|
if (step.properties) Object.assign(this.target, step.properties);
|
|
this.index++; this.elapsed = 0; this.initial = null;
|
|
}
|
|
this.completed = this.index === this.steps.length;
|
|
}
|
|
static stopAllByTarget(target) { tweens.filter(tween => tween.target === target).forEach(tween => tween.stop()); }
|
|
}
|
|
const cc = { Component: class {}, Node, Tween, tween: target => new Tween(target), _decorator: {},
|
|
Label: {toString:()=> 'cc.Label',Overflow:{SHRINK:2},HorizontalAlign:{CENTER:1},VerticalAlign:{CENTER:1}}, RichText: 'cc.RichText', LabelOutline: 'cc.LabelOutline', Sprite: { toString: () => 'cc.Sprite', SizeMode: { TRIMMED: 1, CUSTOM: 0 } }, Button: 'cc.Button', ProgressBar: 'cc.ProgressBar',
|
|
Layout: 'cc.Layout', Widget: 'cc.Widget', instantiate: clone, isValid: node => !!node && !node.destroyed, find: () => null,
|
|
fx: { GameConfig: { GM_INFO: { sceneValue: 'home' } } }, error: message => { throw Error(message); } };
|
|
const context = { cc, Date, console: { log() {} }, Utils: { getDailyQuestReward: (id, callback) => requests.push([id, response => {
|
|
// DailyTaskSync updates the task cache before notifying the panel.
|
|
if (response.code === 1) cc.fx.GameConfig.GM_INFO.tasks = response.data.task;
|
|
callback(response);
|
|
}]) },
|
|
MiniGameSdk: { API: { showToast() {} } } };
|
|
cc.fx.GameTool = {getDailyTaskServerTime: () => Date.now(), claimDailyQuestReward: context.Utils.getDailyQuestReward};
|
|
cc.fx.GameTool.getCachedDailyQuests = () => null;
|
|
cc.fx.StorageMessage = {
|
|
getStorage: key => storage.has(key) ? structuredClone(storage.get(key)) : null,
|
|
setStorage: (key, value) => storage.set(key, structuredClone(value))
|
|
};
|
|
Object.assign(context, contentModule, { PopupAnimator: animationModule.default });
|
|
vm.runInNewContext(code, context);
|
|
const daily = new context.DailyQuests(); installCountFrames(daily); daily.node = build(1);
|
|
daily.content = daily.node.getChildByName('bg').getChildByName('content');
|
|
daily.taskNormal = 'normal-frame'; daily.taskClaimable = 'claimable-frame'; daily.rewardIcons = [{ getRect: () => ({ width: 84, height: 95 }) }];
|
|
daily.schedule = () => {}; daily.unschedule = () => {}; daily.scheduleOnce = callback => timers.push(callback);
|
|
daily.openLoad = daily.closeLoad = () => {};
|
|
daily.openReward = (...args) => grants.push(args);
|
|
// Reward-flight timing has its own integration suite; isolate row animation here.
|
|
daily.playDailyRewardAnimation = (row, task, rewards, complete) => { flights.push({ row, task, rewards }); complete(); };
|
|
const template = daily.content.children[0];
|
|
if (options.scale) template.setScale(...options.scale);
|
|
if (options.widgetEnabled !== undefined) template.getComponent('cc.Widget').enabled = options.widgetEnabled;
|
|
const data = { version: 2, weeklyStars: 10, weeklyRewards: [], dailyTasks: states.map((state, index) => ({
|
|
taskId: `daily_${index}`, metric: 'pass_levels', state, value: state === 0 ? 0 : 1, target: 1, stars: 6, rewards: [{ type: 'coin', count: 50 }]
|
|
})) };
|
|
cc.fx.GameConfig.GM_INFO.tasks = data;
|
|
daily.onLoad();
|
|
if (options.initialize !== false) daily.init(data);
|
|
return { daily, data, cc, storage, tweens, requests, timers, grants, flights, rows: daily.content.children,
|
|
advance(seconds) { [...tweens].forEach(tween => tween.advance(seconds)); } };
|
|
}
|
|
|
|
function button(row) { return row.getChildByName('btnGet').getComponent('cc.Button'); }
|
|
function progress(row) { return row.getChildByName('process').getComponent('cc.ProgressBar').progress; }
|
|
function progressText(row) { return countText(row.getChildByName('processLabel')); }
|
|
function progressSetup() {
|
|
const h = setup([0, 0], { initialize: false });
|
|
h.storage.set('uid', 'player-a'); h.data.dayKey = '2026-09-29';
|
|
Object.assign(h.data.dailyTasks[0], { target: 10, value: 2 });
|
|
Object.assign(h.data.dailyTasks[1], { target: 1800, value: 360, metric: 'online_seconds' });
|
|
return h;
|
|
}
|
|
|
|
test('daily progress clips a fixed-size rounded fill instead of squeezing its corners', () => {
|
|
const process = prefab.find(value => value._name === 'process');
|
|
const progress = process._components.map(ref => prefab[ref.__id__]).find(value => value.__type__ === 'cc.ProgressBar');
|
|
const sprite = prefab[progress._N$barSprite.__id__], bar = prefab[sprite.node.__id__];
|
|
assert.equal(progress._N$mode, 2, 'ProgressBar uses FILLED mode');
|
|
assert.equal(progress._N$totalLength, 1, 'filled progress is a fraction');
|
|
assert.equal(sprite._type, 3, 'Sprite uses FILLED type');
|
|
assert.equal(sprite._fillType, 0, 'fill is horizontal');
|
|
assert.equal(sprite._fillStart, 0);
|
|
const meta = JSON.parse(fs.readFileSync(path.join(root, 'assets/action_bundle/img/dailyQuest/process-bar.png.meta')));
|
|
assert.equal(bar._contentSize.width, meta.width);
|
|
assert.equal(bar._contentSize.height, meta.height);
|
|
assert.ok(bar._trs.array[0] > 0);
|
|
assert.ok(bar._trs.array[0] + bar._contentSize.width < process._contentSize.width);
|
|
assert.ok(Math.abs(bar._trs.array[1]) + bar._contentSize.height / 2 < process._contentSize.height / 2);
|
|
});
|
|
|
|
test('task progress uses task digit and slash sprites in the prefab and during updates', () => {
|
|
const h = progressSetup(), component = prefab.find(value => value.taskDigits);
|
|
assert.deepEqual(component.taskDigits.map(ref => ref.__uuid__), h.daily.taskDigits.map(frame => frame.uuid));
|
|
assert.equal(component.taskSlash.__uuid__, h.daily.taskSlash.uuid);
|
|
const template = prefab.find(value => value._name === 'processLabel');
|
|
assert.equal(template._components.length, 0);
|
|
assert.equal(template._children.length, 4);
|
|
template._children.forEach((ref, index) => {
|
|
const glyph = prefab[ref.__id__], sprite = prefab[glyph._components[0].__id__];
|
|
const token = '0/10'[index];
|
|
assert.equal(sprite.__type__, 'cc.Sprite');
|
|
assert.equal(sprite._spriteFrame.__uuid__, token === '/' ? h.daily.taskSlash.uuid : h.daily.taskDigits[Number(token)].uuid);
|
|
});
|
|
for (const [value, target] of [[0, 0], [12345, 67890], [999999, 999999]]) {
|
|
h.daily.renderDailyTaskProgress(h.rows[0], { target, metric: 'clear_blocks' }, value);
|
|
assert.equal(progressText(h.rows[0]), `${value}/${target}`);
|
|
const container = h.rows[0].getChildByName('processLabel');
|
|
const group = container.getChildByName('RewardCountGlyphs');
|
|
group.children.filter(glyph => glyph.active).forEach(glyph => {
|
|
const frame = glyph.getComponent('cc.Sprite').spriteFrame;
|
|
assert.ok(h.daily.taskDigits.includes(frame) || frame === h.daily.taskSlash);
|
|
assert.equal(glyph.getComponent('cc.Label'), null);
|
|
assert.ok((Math.abs(glyph.x) + glyph.width * glyph.scale / 2) * group.scale <= container.width / 2 + 1e-6);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('opening animates all task progress together, caches the final values, and displays online minutes', () => {
|
|
const h = progressSetup(); h.daily.init(h.data);
|
|
assert.equal(h.tweens.length, 1);
|
|
assert.equal(progressText(h.rows[0]), '0/10'); assert.equal(progressText(h.rows[1]), '0/30');
|
|
assert.equal(h.daily.touchStart, false);
|
|
const snapshot = h.storage.get('dailyTaskOpenedProgress:player-a');
|
|
assert.deepEqual(Object.values(snapshot.values), [2, 360]);
|
|
h.advance(0.3);
|
|
assert.ok(progress(h.rows[0]) > 0 && progress(h.rows[0]) < 0.2);
|
|
assert.equal(progress(h.rows[0]), progress(h.rows[1]));
|
|
h.advance(0.3);
|
|
assert.equal(progressText(h.rows[0]), '2/10'); assert.equal(progressText(h.rows[1]), '6/30');
|
|
assert.equal(h.daily.touchStart, true);
|
|
assert.deepEqual(h.data.dailyTasks.map(task => task.value), [2, 360]);
|
|
|
|
h.daily.onDisable();
|
|
h.data.dailyTasks[0].value = 8; h.data.dailyTasks[1].value = 900;
|
|
h.daily.init(h.data);
|
|
assert.equal(progressText(h.rows[0]), '2/10'); assert.equal(progressText(h.rows[1]), '6/30');
|
|
h.advance(0.3);
|
|
assert.equal(progressText(h.rows[0]), '5/10');
|
|
assert.ok(progress(h.rows[1]) > 0.2 && progress(h.rows[1]) < 0.5);
|
|
h.advance(0.3);
|
|
assert.equal(progressText(h.rows[0]), '8/10'); assert.equal(progressText(h.rows[1]), '15/30');
|
|
});
|
|
|
|
test('progress reaches the goal before completion jelly animations and claims stay locked through both stages', () => {
|
|
const h = progressSetup();
|
|
h.data.dailyTasks.forEach(task => { task.value = task.target; task.state = 1; });
|
|
h.daily.init(h.data);
|
|
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 0);
|
|
h.advance(0.3); assert.equal(h.rows[0].scale, 1); assert.equal(h.tweens.length, 1);
|
|
h.advance(0.3);
|
|
assert.equal(h.tweens.length, 3);
|
|
assert.ok(h.rows.every(row => progress(row) === 1));
|
|
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 0);
|
|
h.advance(0.14);
|
|
assert.ok(h.rows.every(row => !button(row).interactable));
|
|
h.advance(0.34);
|
|
assert.ok(h.rows.every(row => button(row).interactable));
|
|
h.daily.getReward(null, 'daily_0'); assert.equal(h.requests.length, 1);
|
|
});
|
|
|
|
test('background refresh is deferred during progress and never overwrites the opening snapshot', () => {
|
|
const h = progressSetup(); h.daily.init(h.data);
|
|
const updated = structuredClone(h.data); updated.dailyTasks[0].value = 7;
|
|
h.cc.fx.GameTool.getCachedDailyQuests = () => updated;
|
|
h.daily.refreshFromCache();
|
|
assert.equal(h.daily.cacheRefreshPending, true);
|
|
assert.equal(progressText(h.rows[0]), '0/10');
|
|
h.advance(0.6); h.daily.updateCountdowns();
|
|
assert.equal(progressText(h.rows[0]), '7/10');
|
|
assert.deepEqual(Object.values(h.storage.get('dailyTaskOpenedProgress:player-a').values), [2, 360]);
|
|
assert.equal(h.tweens.length, 1);
|
|
h.daily.onDisable(); h.daily.init(updated);
|
|
assert.equal(progressText(h.rows[0]), '2/10');
|
|
});
|
|
|
|
test('closing cancels progress without starting completion animations or changing a reopened panel', () => {
|
|
const h = progressSetup(); h.data.dailyTasks[0].value = 10; h.data.dailyTasks[0].state = 1;
|
|
h.daily.init(h.data); h.advance(0.2);
|
|
const oldTween = h.tweens[0];
|
|
h.daily.node.active = false; h.daily.onDisable();
|
|
assert.equal(oldTween.cancelled, true); assert.equal(h.tweens.length, 1);
|
|
h.daily.node.active = true; h.daily.init(h.data);
|
|
const count = h.tweens.length;
|
|
oldTween.advance(10); assert.equal(h.tweens.length, count);
|
|
assert.equal(progress(h.rows[0]), 1);
|
|
h.advance(1); assertClaimable(h.rows[0], true);
|
|
});
|
|
|
|
test('snapshots separate players, dates and task configurations, and never animate progress backwards', () => {
|
|
for (const change of [
|
|
h => h.storage.set('uid', 'player-b'),
|
|
h => h.data.dayKey = '2026-09-30',
|
|
h => h.data.dailyTasks[0].params = { colorId: 3 }
|
|
]) {
|
|
const h = progressSetup(); h.daily.init(h.data); h.advance(1); h.daily.onDisable();
|
|
change(h); h.daily.init(h.data);
|
|
assert.equal(progress(h.rows[0]), 0);
|
|
}
|
|
const h = progressSetup(); h.daily.init(h.data); h.advance(1); h.daily.onDisable();
|
|
h.data.dailyTasks[0].value = 1; h.daily.init(h.data);
|
|
assert.equal(progressText(h.rows[0]), '1/10'); assert.equal(h.daily.dailyTaskProgressTween, null);
|
|
});
|
|
|
|
test('online progress keeps second precision in the bar while labels use whole minutes; storage failure is harmless', () => {
|
|
const h = progressSetup(); h.data.dailyTasks[1].value = 390;
|
|
h.cc.fx.StorageMessage.setStorage = () => { throw new Error('storage full'); };
|
|
h.daily.init(h.data);
|
|
assert.equal(progressText(h.rows[1]), '6/30');
|
|
assert.equal(progress(h.rows[1]), 390 / 1800);
|
|
assert.equal(h.daily.touchStart, true);
|
|
});
|
|
function assertClaimable(row, interactable) {
|
|
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'claimable-frame');
|
|
assert.equal(row.getChildByName('btnGet').active, true);
|
|
assert.equal(button(row).interactable, interactable);
|
|
assert.equal(row.getChildByName('desc').active, false);
|
|
assert.equal(row.getChildByName('process').active, false);
|
|
assert.equal(row.getChildByName('processLabel').active, false);
|
|
}
|
|
function assertClaimed(row) {
|
|
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame');
|
|
assert.equal(row.getChildByName('btnGet').active, false);
|
|
assert.equal(button(row).interactable, false);
|
|
assert.equal(row.getChildByName('complete').active, true);
|
|
assert.equal(row.getChildByName('rewards').active, false);
|
|
assert.equal(row.getChildByName('desc').active, true);
|
|
assert.equal(row.getChildByName('process').active, false);
|
|
assert.equal(row.getChildByName('processLabel').active, false);
|
|
}
|
|
function claimedResponse(h, taskId = 'daily_0') {
|
|
const refreshed = structuredClone(h.data);
|
|
refreshed.dailyTasks.find(task => task.taskId === taskId).state = 2;
|
|
refreshed.weeklyStars += 6;
|
|
return { code: 1, data: { task: refreshed, rewards: [{ type: 'coin', count: 50 }] } };
|
|
}
|
|
|
|
test('all unclaimed completed tasks transition together from normal through 1.1 scale, with claims locked until settled', () => {
|
|
const h = setup(), ready = h.rows.filter((row, index) => h.data.dailyTasks[index].state === 1);
|
|
assert.deepEqual(h.tweens.map(tween => tween.target), ready);
|
|
assert.deepEqual(h.data.dailyTasks.map(task => task.state), [0, 1, 1, 2], 'display animation must not mutate server state');
|
|
assert.ok(h.tweens.every(tween => tween.steps[0].properties), 'all rows start shrinking immediately without stagger delays');
|
|
for (const row of ready) {
|
|
assert.equal(row.scale, 1);
|
|
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame');
|
|
assert.equal(row.getChildByName('desc').active, true);
|
|
assert.equal(row.getChildByName('process').active, true);
|
|
assert.equal(row.getChildByName('processLabel').active, true);
|
|
assert.equal(row.getChildByName('btnGet').active, false);
|
|
assert.equal(button(row).interactable, false);
|
|
h.daily.getReward(null, row.name);
|
|
}
|
|
assert.equal(h.requests.length, 0);
|
|
h.advance(0.07);
|
|
assert.ok(ready.every(row => row.scale > 0 && row.scale < 1));
|
|
assert.ok(ready.every(row => row.getChildByName('background').getComponent('cc.Sprite').spriteFrame === 'normal-frame'));
|
|
h.advance(0.07);
|
|
for (const row of ready) { assert.equal(row.scale, 0); assertClaimable(row, false); }
|
|
h.advance(0.22);
|
|
for (const row of ready) { assert.equal(row.scale, 1.1); assertClaimable(row, false); h.daily.getReward(null, row.name); }
|
|
assert.equal(h.requests.length, 0, 'visible claim buttons must still reject programmatic clicks at the overshoot');
|
|
h.advance(0.06);
|
|
assert.ok(ready.every(row => row.scale > 1 && row.scale < 1.1));
|
|
h.advance(0.06);
|
|
for (const row of ready) { assert.equal(row.scale, 1); assertClaimable(row, true); }
|
|
assert.equal(h.rows[0].getChildByName('process').active, true);
|
|
assert.equal(h.rows[3].getChildByName('complete').active, true);
|
|
assert.equal(h.rows[3].getChildByName('rewards').active, false);
|
|
assert.equal(h.requests.length, 0);
|
|
});
|
|
|
|
test('a row finishing early cannot enable claims before every opening animation finishes', () => {
|
|
const h = setup([1, 1]);
|
|
h.tweens[0].advance(1);
|
|
assert.equal(button(h.rows[0]).interactable, false);
|
|
h.daily.getReward(null, h.rows[0].name);
|
|
assert.equal(h.requests.length, 0);
|
|
h.tweens[1].advance(1);
|
|
assert.ok(h.rows.every(row => button(row).interactable));
|
|
});
|
|
|
|
test('claim transitions are independent: another row remains clickable while the first shrinks and settles', () => {
|
|
const h = setup([1, 1, 2]); h.advance(1);
|
|
const [row, other] = h.rows, openingCount = h.tweens.length;
|
|
h.daily.getReward(null, row.name);
|
|
assertClaimable(row, false); assertClaimable(other, true);
|
|
h.daily.getReward(null, row.name); assert.equal(h.requests.length, 1);
|
|
const first = claimedResponse(h);
|
|
h.requests[0][1](first);
|
|
assert.equal(h.grants.length, 1); assert.equal(h.daily.touchStart, true);
|
|
h.advance(0.07); assert.ok(row.scale > 0 && row.scale < 1);
|
|
h.daily.getReward(null, other.name); assert.equal(h.requests.length, 2);
|
|
const second = structuredClone(first); second.data.task.dailyTasks[1].state = 2;
|
|
second.data.task.weeklyStars += 6;
|
|
h.requests[1][1](second);
|
|
assert.equal(h.grants.length, 2);
|
|
assert.equal(h.daily.dailyTaskStateAnimations.length, 2);
|
|
assert.equal(h.tweens[openingCount].cancelled, false);
|
|
h.tweens[openingCount].advance(1);
|
|
assertClaimed(row); assert.equal(h.daily.dailyTaskStateAnimations.length, 1);
|
|
assert.equal(h.tweens[openingCount + 1].cancelled, false);
|
|
assert.equal(other.scale, 1); assertClaimable(other, false);
|
|
h.advance(0.14); assert.equal(other.scale, 0); assertClaimed(other);
|
|
h.advance(0.22); assert.equal(other.scale, 1.1);
|
|
h.advance(0.12); assert.equal(other.scale, 1);
|
|
assert.equal(h.daily.dailyTaskStateAnimations.length, 0);
|
|
assert.equal(h.daily.reward, false);
|
|
h.requests[0][1](first); assert.equal(h.grants.length, 2);
|
|
});
|
|
|
|
test('failure unlocks only the failed row, while a slow pending request stays protected against duplicates', () => {
|
|
const h = setup([1, 1]); h.advance(1);
|
|
const openingCount = h.tweens.length;
|
|
h.daily.getReward(null, h.rows[0].name); h.daily.getReward(null, h.rows[1].name);
|
|
assert.equal(h.requests.length, 2);
|
|
h.timers[0]();
|
|
h.daily.getReward(null, h.rows[0].name); assert.equal(h.requests.length, 2);
|
|
h.requests[1][1]({ code: 0 });
|
|
assertClaimable(h.rows[0], false); assertClaimable(h.rows[1], true);
|
|
h.daily.getReward(null, h.rows[1].name); assert.equal(h.requests.length, 3);
|
|
assert.equal(h.grants.length, 0); assert.equal(h.tweens.length, openingCount);
|
|
assert.equal(h.daily.touchStart, true);
|
|
});
|
|
|
|
test('claim responses match the displayed row by task ID when the server changes task order', () => {
|
|
const h = setup([1, 1, 2]); h.advance(1);
|
|
const row = h.rows[0], other = h.rows[1], openingCount = h.tweens.length;
|
|
h.daily.getReward(null, row.name);
|
|
const response = claimedResponse(h); response.data.task.dailyTasks.reverse();
|
|
h.requests[0][1](response);
|
|
assert.equal(h.tweens.length, openingCount + 1);
|
|
assert.equal(h.tweens[openingCount].target, row);
|
|
assert.equal(row.name, 'daily_0'); assertClaimable(row, false);
|
|
h.advance(0.14); assertClaimed(row);
|
|
assert.equal(other.name, 'daily_1'); assertClaimable(other, true);
|
|
h.advance(1);
|
|
assertClaimed(h.daily.content.getChildByName('daily_0'));
|
|
assertClaimable(h.daily.content.getChildByName('daily_1'), true);
|
|
});
|
|
|
|
test('closing during a claim cancels its transition, restores layout, and prevents stale callbacks after reopening', () => {
|
|
for (const elapsed of [0.07, 0.21, 0.42]) {
|
|
const h = setup([1, 1], { scale: [0.8, 0.65] }); h.advance(1);
|
|
const [row, other] = h.rows, openingCount = h.tweens.length;
|
|
h.daily.getReward(null, row.name);
|
|
const response = claimedResponse(h); h.requests[0][1](response);
|
|
const transition = h.tweens[openingCount];
|
|
let completions = 0;
|
|
const animation = h.daily.dailyTaskStateAnimations[0];
|
|
const complete = animation.complete;
|
|
animation.complete = () => { completions++; complete(); };
|
|
h.advance(elapsed); h.daily.node.active = false; h.daily.onDisable(); h.daily.onDisable();
|
|
assert.equal(completions, 1, 'cancelled state animations consume their completion callback once');
|
|
assert.equal(transition.cancelled, true);
|
|
assert.equal(row.scaleX, 0.8); assert.equal(row.scaleY, 0.65);
|
|
assert.equal(row.getComponent('cc.Widget').enabled, true); assertClaimed(row);
|
|
assert.equal(h.daily.touchStart, true); assert.equal(h.daily.reward, false);
|
|
assert.equal(h.grants.length, 1);
|
|
assert.equal(h.tweens.length, openingCount + 1, 'closing must not replay other claimable tasks');
|
|
h.daily.node.active = true; h.daily.init(response.data.task);
|
|
assert.deepEqual(h.tweens.slice(openingCount + 1).map(tween => tween.target), [other], 'a fresh opening animates only tasks still awaiting a claim');
|
|
assertClaimed(row);
|
|
transition.advance(1);
|
|
assert.equal(completions, 1);
|
|
assert.equal(other.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame', 'the stale callback cannot finish the new opening animation');
|
|
h.advance(1);
|
|
assertClaimed(row); assertClaimable(other, true);
|
|
assert.equal(h.grants.length, 1);
|
|
}
|
|
});
|
|
|
|
test('refreshing after a claim displays updated tasks without replaying the opening animation', () => {
|
|
const h = setup([1, 1]); h.advance(1);
|
|
const refreshed = structuredClone(h.data); refreshed.dailyTasks[0].state = 2; refreshed.weeklyStars += 6;
|
|
h.daily.init(refreshed);
|
|
assert.equal(h.tweens.length, 2);
|
|
assert.equal(h.rows[0].getChildByName('complete').active, true);
|
|
assert.equal(h.rows[0].getChildByName('rewards').active, false);
|
|
assertClaimable(h.rows[1], true);
|
|
assert.equal(h.requests.length, 0);
|
|
});
|
|
|
|
test('a refresh during the opening animation stops old tweens and restores current task presentation', () => {
|
|
const h = setup([1, 1]); h.advance(0.21);
|
|
const refreshed = structuredClone(h.data); refreshed.dailyTasks[0].state = 2;
|
|
h.daily.init(refreshed);
|
|
assert.ok(h.tweens.every(tween => tween.cancelled));
|
|
assert.equal(h.tweens.length, 2);
|
|
h.advance(1);
|
|
assert.ok(h.rows.every(row => row.scaleX === 1 && row.scaleY === 1));
|
|
assert.equal(h.rows[0].getChildByName('complete').active, true);
|
|
assertClaimable(h.rows[1], true);
|
|
});
|
|
|
|
test('closing at any stage restores scales and widget state, and reopening starts a fresh animation', () => {
|
|
for (const elapsed of [0.07, 0.21, 0.42]) {
|
|
for (const widgetEnabled of [false, true]) {
|
|
const h = setup([1], { scale: [0.8, 0.65], widgetEnabled }), row = h.rows[0], widget = row.getComponent('cc.Widget');
|
|
assert.equal(widget.enabled, false, 'alignment must not resize a shrinking row');
|
|
if (widgetEnabled) assert.ok(widget.alignments > 0, 'initial layout is resolved before scaling');
|
|
h.advance(elapsed); h.daily.node.active = false; h.daily.onDisable();
|
|
assert.equal(row.scaleX, 0.8); assert.equal(row.scaleY, 0.65);
|
|
assert.equal(widget.enabled, widgetEnabled);
|
|
assert.equal(h.tweens[0].cancelled, true);
|
|
h.daily.node.active = true; h.daily.init(h.data);
|
|
assert.equal(h.tweens.length, 2);
|
|
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame');
|
|
h.tweens[0].advance(1);
|
|
assert.equal(row.getChildByName('background').getComponent('cc.Sprite').spriteFrame, 'normal-frame', 'cancelled animation cannot alter the reopened panel');
|
|
h.advance(1);
|
|
assert.equal(row.scaleX, 0.8); assert.equal(row.scaleY, 0.65);
|
|
assert.equal(widget.enabled, widgetEnabled); assertClaimable(row, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('destroying an animating panel stops tweens, restores row layout, and removes listeners', () => {
|
|
const h = setup([1], { scale: [0.9, 0.7] }); h.advance(0.2);
|
|
h.daily.onDestroy(); h.daily.onDestroy(); h.advance(1);
|
|
assert.ok(h.tweens.every(tween => tween.cancelled));
|
|
assert.equal(h.rows[0].scaleX, 0.9); assert.equal(h.rows[0].scaleY, 0.7);
|
|
assert.equal(h.rows[0].getComponent('cc.Widget').enabled, true);
|
|
assert.equal(h.daily.node.listeners.length, 0);
|
|
});
|
|
|
|
test('panels with no claimable tasks do not animate, and later refreshes remain immediate until reopening', () => {
|
|
const h = setup([0, 2]);
|
|
assert.equal(h.tweens.length, 0); assert.equal(h.daily.touchStart, true);
|
|
const refreshed = structuredClone(h.data); refreshed.dailyTasks[0].state = 1;
|
|
h.daily.init(refreshed); assert.equal(h.tweens.length, 0); assertClaimable(h.rows[0], true);
|
|
h.daily.node.active = false; h.daily.onDisable(); h.daily.node.active = true; h.daily.init(refreshed);
|
|
assert.equal(h.tweens.length, 1);
|
|
});
|
|
|
|
test('invalid initialization does not consume the first valid opening animation', () => {
|
|
const h = setup([1], { initialize: false });
|
|
h.daily.init(null); h.daily.init({ version: 1 });
|
|
assert.equal(h.tweens.length, 0);
|
|
h.daily.init(h.data); assert.equal(h.tweens.length, 1);
|
|
});
|