This commit is contained in:
Mauve
2026-09-16 10:43:59 +08:00
parent a7f2e0ca01
commit 9f2becfa67
36 changed files with 3997 additions and 264 deletions
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const source=await readFile(new URL('../booking/bookingRecord/bookingRecord.vue',import.meta.url),'utf8');
function computed(name,next) {
const start=source.indexOf(name+'(){');
const end=source.indexOf(next+'()',start);
assert.ok(start>=0 && end>start);
return new Function(`return ({${source.slice(start,end).trim().replace(/,$/,'')}}).${name}();`)();
}
const color=computed('getRecordColor','getETA');
const confirmation=computed('getConfirmationNo','dateFormat');
test('AP retains approval color when lodging status also matches an ordinary booking',()=>{
for(const lastOperaStatus of ['CHECKED_IN','DUE_OUT','RESERVED',null]) {
for(const sharer of [null,'[]','']) {
assert.equal(color({approveStatus:'AP',lastOperaStatus,confirmationNo:'12345',sharer}),'hz-card-approval');
}
}
});
test('waitlist and ordinary colors are preserved',()=>{
for(const approveStatus of ['W','WA']) assert.equal(color({approveStatus,lastOperaStatus:'CHECKED_IN',confirmationNo:'12345'}),'hz-card-waitlist');
assert.equal(color({approveStatus:'N',lastOperaStatus:'CHECKED_IN',confirmationNo:'12345'}),'hz-card-booking');
});
test('Daylight still displays an existing AP confirmation number',()=>{
assert.equal(confirmation({approveStatus:'AP',confirmationNo:'12345'}),'12345');
assert.equal(confirmation({approveStatus:'AP',confirmationNo:null}),'N/A');
assert.equal(confirmation({approveStatus:'W',confirmationNo:'12345'}),'N/A');
});
+35
View File
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const source=await readFile(new URL('../booking/newBooking/newBooking.vue',import.meta.url),'utf8');
const script=source.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import[\s\S]*?from\s+['"][^'"]+['"];?[^\n]*/g,'').replace('export default','return');
function setup() {
let stored,scheduled,delay;
const calls=[];
const page=new Function('uni','playerLookup','setTimeout','clearTimeout',script)(
{getStorageSync:()=>structuredClone(stored),setStorageSync:(k,v)=>stored=structuredClone(v),
showLoading(){},hideLoading(){},$u:{toast(){}}},
async params=>{calls.push(params);return {success:true,data:{englishName:'Original Name',tierName:'Gold'}};},
(fn,ms)=>{scheduled=fn;delay=ms;return 1;},()=>{});
const vm={modelBooking:{sharerList:[{playerId:'80001',name:'',tierName:''}]},
playerData:{playerId:'90001'},userData:{userId:'1'},$t:k=>k,currentTask:null,getPlayerTimer:null};
for(const [k,fn] of Object.entries(page.methods))vm[k]=fn.bind(vm);
return {vm,calls,run:()=>scheduled(),delay:()=>delay};
}
test('Daylight delayed lookup and tier fill survive; editing resolved name clears only player ID',async()=>{
const {vm,calls,run,delay}=setup();
await vm.getPlayer(0);assert.equal(delay(),800);assert.equal(calls.length,0);
await run();assert.equal(calls[0].playerId,'80001');
const item=vm.modelBooking.sharerList[0];
assert.equal(item.name,'Original Name');assert.equal(item.tierName,'Gold');
await vm.getPlayerName(0);assert.equal(item.playerId,'80001');
item.name='Edited Name';await vm.getPlayerName(0);
assert.equal(item.playerId,'');assert.equal(item.name,'Edited Name');
});
test('source length threshold is retained for partial IDs',async()=>{
const {vm,run}=setup();await vm.getPlayer(0);await run();
const item=vm.modelBooking.sharerList[0];item.name='Edited Name';item.playerId='1234';
await vm.getPlayerName(0);assert.equal(item.playerId,'1234');
item.playerId='12345';await vm.getPlayerName(0);assert.equal(item.playerId,'');
});
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const source=await readFile(new URL('../booking/submitBooking/submitBooking.vue',import.meta.url),'utf8');
const script=source.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import[\s\S]*?from\s+['"][^'"]+['"];?[^\n]*/g,'').replace('export default','return');
function setup(bookType=0) {
const saved=[],logs=[];
const page=new Function('uni','saveBooking','getApprover',script)(
{showLoading(){},hideLoading(){},showToast(){},$u:{throttle(){}}},
async data=>{saved.push(structuredClone(data));return {success:true};},
async()=>({success:true,data:{records:[{approverList:[{employeeId:'7',employeeName:'Approver'}],approvalRoutingMessage:'Route'}]}}));
const vm={$t:k=>k,$set:(o,k,v)=>o[k]=v,writeLog:async(...args)=>logs.push(args)};
Object.assign(vm,page.data.call(vm));
for(const [k,fn] of Object.entries(page.methods))vm[k]=fn.bind(vm);
for(const [k,fn] of Object.entries(page.computed))Object.defineProperty(vm,k,{get:fn.bind(vm)});
vm.bookingData={bookType,dateList:[{date:['2026-09-09','2026-09-10'],status:1}],
extendDate:[{date:['2026-09-10','2026-09-11'],status:1}],roomRemark:'',otherRemark:''};
vm.checkList=[0]; vm.playerData={playerId:'80001'};vm.userData={userId:'1'};
return {vm,saved,logs};
}
test('direct Waitlist initializes selected dates for normal and extension booking',()=>{
for(const type of [0,1]) {
const {vm}=setup(type); vm.showWaitlistPop();
assert.equal(vm.unqualifiedDateList[0],type?vm.bookingData.extendDate[0]:vm.bookingData.dateList[0]);
assert.deepEqual(vm.unqualifiedDateCheckboxStates,[[]]);assert.equal(vm.showWaitlistModal,true);
}
});
test('Waitlist room choice is mutually exclusive and can be deselected',()=>{
const {vm}=setup();
vm.onWaitlistRoomRemarkGroupChange(['2nd Room']);
assert.deepEqual(vm.waitlistRoomGroupValue,['2nd Room']);
vm.onWaitlistRoomRemarkGroupChange(['2nd Room','3rd Room']);
assert.deepEqual(vm.waitlistRoomGroupValue,['3rd Room']);
vm.onWaitlistRoomRemarkGroupChange([]);assert.deepEqual(vm.waitlistRoomGroupValue,[]);
});
test('Waitlist remark includes Extend first and preserves date-specific manual text',()=>{
const {vm}=setup();vm.showWaitlistPop();vm.onWaitlistRoomRemarkGroupChange(['2nd Room']);
vm.changeWaitlistOtherRemark(['Extend','Remark']);vm.unqualifiedDateRemarks[0]='Late arrival';
vm.getWaitlistRemark(1,0);
assert.equal(vm.bookingData.dateList[0].remark,'Extend; 2nd Room; Late arrival');
vm.changeWaitlistOtherRemark([]);vm.onWaitlistRoomRemarkGroupChange([]);vm.unqualifiedDateRemarks[0]='';
vm.getWaitlistRemark(1,0);assert.equal(vm.bookingData.dateList[0].remark,'');
});
test('Waitlist date matching survives reconstructed objects',()=>{
const {vm}=setup();vm.showWaitlistPop();vm.changeWaitlistOtherRemark(['Extend','Remark']);
vm.unqualifiedDateRemarks[0]='Date note';
vm.bookingData.dateList[0]=structuredClone(vm.bookingData.dateList[0]);
vm.getWaitlistRemark(1,0);assert.equal(vm.bookingData.dateList[0].remark,'Extend; Date note');
});
test('different existing and manual remarks are both preserved',()=>{
const {vm}=setup();vm.showWaitlistPop();vm.changeWaitlistOtherRemark(['Remark']);
vm.bookingData.dateList[0].remarkText='Existing note';vm.unqualifiedDateRemarks[0]='Additional note';
vm.getWaitlistRemark(1,0);
assert.equal(vm.bookingData.dateList[0].remark,'Existing note; Additional note');
});
test('extension approval submits extendDate remark and approver fields',async()=>{
const {vm,saved,logs}=setup(1);vm.showWaitlistPop();
vm.unqualifiedDateReasons[0]='reason';vm.unqualifiedDateApprovers[0]=['7'];
vm.unqualifiedDateRemarks[0]='Extension note';vm.changeWaitlistOtherRemark(['Extend','Remark']);
vm.selectApproverData=[{value:'7',label:'Approver'}];
await vm.submitApprovalRequest();
assert.equal(logs.length,0);assert.equal(saved.length,1);
assert.equal(saved[0].extendDate[0].remarkText,'Extension note');
assert.equal(saved[0].extendDate[0].approver,'7');assert.equal(saved[0].isApproval,1);
assert.equal(saved[0].extendDate[0].remark,'Extend; Extension note');
assert.equal(saved[0].extendData,undefined);
});
test('approver response retains Daylight records envelope',async()=>{
const {vm}=setup();await vm.getApprover();
assert.equal(vm.selectApproverData[0].value,'7');assert.equal(vm.selectApproverData[0].label,'Approver');
assert.equal(vm.routingResaon,'Route');
});
+100
View File
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
async function load(relative) {
const source = await readFile(new URL(relative, import.meta.url), 'utf8');
return import('data:text/javascript;base64,' + Buffer.from(source).toString('base64'));
}
const ui = await load('../common/roomCalendarUi.js');
const normalizer = await load('../common/normalizeApiKeys.js');
const api = await load('../api/roomCalendarApi.js');
const configApi = await load('../api/configApi.js');
const weekdayLabels = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
test('API routes preserve auth and request-task options through Daylight transport', async () => {
const calls = [];
globalThis.uni = { $u: { http: {
post: (...args) => { calls.push(['POST',...args]); return Promise.resolve('ok'); },
get: (...args) => { calls.push(['GET',...args]); return Promise.resolve('ok'); }
}}};
try {
const params = { Property:'MO', RoomType:'SUITE', PlayerId:'123' };
const options = { custom:{auth:true}, getTask:()=>{} };
await api.requestRoomCalendar(params,options);
await api.getPlayerRoomQualification(params,options);
await api.getPropertiesRoomTypes({},options);
await configApi.getCalendarSetting({},options);
await configApi.getCfgSettingByKey({keyName:'MACAU_STRING'},options);
assert.equal(calls[0][1],'/app/room_calendar/request_room_calendar');
assert.equal(calls[1][1],calls[0][1]);
assert.equal(calls[0][2],params);
assert.equal(calls[0][3],options);
assert.equal(calls[2][1],'/app/room_calendar/get_properties_room_types');
assert.equal(calls[2][2].custom,options.custom);
assert.equal(calls[2][2].getTask,options.getTask);
assert.equal(calls[3][1],'/app/setting/get_calendar_setting');
assert.equal(calls[3][3],options);
assert.equal(calls[4][1],'/web/setting/get_public_setting_by_key');
} finally { delete globalThis.uni; }
});
test('PascalCase normalization preserves nested values and input objects', () => {
const original = { Records:[{RoomType:'SUITE',RoomCount:0,Nested:{Enabled:false}}],Data:null };
const snapshot = structuredClone(original);
assert.deepEqual(normalizer.normalizeApiKeys(original),
{records:[{roomType:'SUITE',roomCount:0,nested:{enabled:false}}],data:null});
assert.deepEqual(original,snapshot);
assert.deepEqual(normalizer.getApiRecords(original),original.Records);
assert.deepEqual(normalizer.getApiRecords({records:[1]}),[1]);
assert.deepEqual(normalizer.getApiRecords({Records:{}}),[]);
assert.equal(normalizer.normalizeApiKeys(null),null);
});
test('cross-year range has unique month-owned weeks and no visible out-of-range dates', () => {
const months = ui.buildCalendarDateRange({
today:'2026-12-31',startDate:'2026-12-30',endDate:'2027-01-02',weekdayLabels
});
assert.deepEqual(months.map(m=>m.monthKey),['2026-12','2027-01']);
const weeks = ui.buildContinuousCalendarWeeks(months);
assert.equal(new Set(weeks.map(w=>w.weekId)).size,weeks.length);
assert.deepEqual(weeks.flatMap(w=>w.days).filter(d=>!d.isOutsideMonth).map(d=>d.date),
['2026-12-30','2026-12-31','2027-01-01','2027-01-02']);
assert.equal(ui.getTodayWeekId(months),'week-2026-12-2026-12-27');
const firstDate = months[0].weeks[0].days[0].date;
weeks[0].days[0].date = 'changed';
assert.equal(months[0].weeks[0].days[0].date,firstDate);
});
test('leap day is retained and adjacent month cells remain blank', () => {
const months = ui.buildCalendarDateRange({
today:'2028-02-29',startDate:'2028-02-28',endDate:'2028-03-01',weekdayLabels
});
const days = ui.buildContinuousCalendarWeeks(months).flatMap(w=>w.days);
assert.deepEqual(days.filter(d=>!d.isOutsideMonth).map(d=>d.date),
['2028-02-28','2028-02-29','2028-03-01']);
assert.equal(days.filter(d=>d.isToday).length,1);
assert.ok(days.filter(d=>d.isOutsideMonth).every(d=>d.dayNo===''));
});
test('both response casings map dates counts qualification and leader consistently', () => {
const upper = {Date:'2026-09-10T00:00:00',RoomType:'SUITE',RoomCriteria:1200,
RoomCount:'2',PropertyLeader:'Leader',BookingDateQualificationFlag:'1'};
const lower = normalizer.normalizeApiKeys(upper);
const expected = {'2026-09-10_SUITE':{criteria:'1,200',count:2,leader:'Leader',
qualified:true,statusText:'Qualified'}};
assert.deepEqual(ui.mapRoomCalendarQualificationRecords([upper]),expected);
assert.deepEqual(ui.mapRoomCalendarQualificationRecords([lower]),expected);
assert.deepEqual(ui.mapRoomCalendarQualificationRecords([{Date:'',RoomType:'X'}]),{});
});
test('zero availability and denied qualification are preserved', () => {
const mapped = ui.mapRoomCalendarQualificationRecords([{
date:'2026-09-10',roomType:'X',roomCount:0,roomCriteria:null,
bookingDateQualificationFlag:0,leader:'Fallback'
}])['2026-09-10_X'];
assert.equal(mapped.criteria,'-');
assert.equal(mapped.leader,'Fallback');
assert.equal(ui.getAvailabilityStatusClass(mapped),'zero');
assert.equal(ui.getCriteriaStatusClass(mapped),'unqualified');
});
+134
View File
@@ -0,0 +1,134 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const dayjs = require('../uni_modules/uview-ui/libs/util/dayjs.js');
const utilitySource = await readFile(new URL('../common/roomCalendarUi.js',import.meta.url),'utf8');
const ui = await import('data:text/javascript;base64,'+Buffer.from(utilitySource).toString('base64'));
const pageSource = await readFile(new URL('../pages/roomCalendar/roomCalendar.vue',import.meta.url),'utf8');
const appSource = await readFile(new URL('../App.vue',import.meta.url),'utf8');
const tabMethod = appSource.slice(appSource.indexOf('showTabbarByPermission(userData){'),
appSource.indexOf('// 返回',appSource.indexOf('showTabbarByPermission(userData){'))).trim().replace(/,$/,'');
const applyTabPermissions = new Function('uni',`return ({${tabMethod}}).showTabbarByPermission;`);
const script = pageSource.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import[\s\S]*?from\s+['"][^'"]+['"];?/g,'').replace('export default','return');
function setup({permission=true,metadata,availability}={}) {
const calls = [];
const toasts = [];
const user = {canViewCalendar:permission};
globalThis.uni = {
getLocale:()=> 'en', getStorageSync:()=>user,
showLoading(){},hideLoading(){},$u:{toast:m=>toasts.push(m)}
};
const page = new Function('dayjs','buildCalendarDateRange','getTodayWeekId',
'mapRoomCalendarQualificationRecords','getPropertiesRoomTypes','requestRoomCalendar',
'requestCalendarSetting',script)(dayjs,ui.buildCalendarDateRange,ui.getTodayWeekId,
ui.mapRoomCalendarQualificationRecords,
async()=>{calls.push('metadata');return metadata ?? {success:true,propertyList:[
{code:'Macau',roomTypes:[{code:'SUITE',label:'SUITE'}],defaultRoomTypes:['SUITE']}
]};},
async(params,config)=>{calls.push(params);return availability ? availability(params,config) : {success:true,data:[]};},
async()=>{calls.push('settings');return {success:true,data:[]};});
const vm = {$t:k=>k,$i18n:{},$nextTick:fn=>fn(),showTabbarByPermission(){}};
Object.assign(vm,page.data.call(vm));
vm.userData = user;
for(const [key,fn] of Object.entries(page.methods)) vm[key]=fn.bind(vm);
for(const [key,fn] of Object.entries(page.computed)) Object.defineProperty(vm,key,{get:fn.bind(vm)});
return {vm,page,calls,toasts};
}
test('direct navigation and refresh without permission issue no queries',async()=>{
const {vm,page,calls}=setup({permission:false});
page.onShow.call(vm);
vm.refreshCalendar();
await vm.loadPropertiesRoomTypes();
assert.equal(vm.calendarNoPermission,true);
assert.equal(vm.calendarReady,false);
assert.deepEqual(calls,[]);
});
test('real tab permission method hides Calendar after an allowed user switches to a denied user',()=>{
const visible = new Map();
const apply = applyTabPermissions({setTabBarItem:({index,visible:value})=>visible.set(index,value)});
apply({canViewCalendar:true});
assert.equal(visible.get(3),true);
for (const user of [{canViewCalendar:false},{},{canViewCalendar:'true'},null,undefined]) {
apply({canViewCalendar:true});
apply(user);
assert.equal(visible.get(3),false);
}
});
test('page onShow applies real tab visibility and blocks denied users before any query',()=>{
const {vm,page,calls}=setup({permission:false});
const visible = new Map();
vm.showTabbarByPermission = applyTabPermissions({setTabBarItem:({index,visible:value})=>visible.set(index,value)});
page.onShow.call(vm);
assert.equal(visible.get(3),false);
assert.equal(vm.calendarNoPermission,true);
assert.deepEqual(calls,[]);
});
test('metadata denial handles Daylight code and stops availability request',async()=>{
const {vm,calls}=setup({metadata:{success:false,code:'NO_PERMISSION'}});
await vm.loadPropertiesRoomTypes();
assert.equal(vm.calendarNoPermission,true);
assert.equal(vm.calendarReady,false);
assert.deepEqual(calls,['settings','metadata']);
});
test('empty property list is reported without accessing first element',async()=>{
const {vm,calls,toasts}=setup({metadata:{success:true,propertyList:[]}});
await vm.loadPropertiesRoomTypes();
assert.equal(vm.calendarReady,false);
assert.deepEqual(calls,['settings','metadata']);
assert.equal(toasts[0],'No calendar properties configured');
});
test('initial availability denial does not re-enable the grid',async()=>{
const {vm}=setup({availability:async()=>({success:false,errorCode:'NO_PERMISSION'})});
await vm.loadPropertiesRoomTypes();
assert.equal(vm.calendarNoPermission,true);
assert.equal(vm.calendarReady,false);
});
test('older availability response cannot overwrite a newer selection',async()=>{
const pending=[];
const {vm}=setup({availability:()=>new Promise(resolve=>pending.push(resolve))});
vm.propertyList=[{code:'Macau',roomTypes:[{code:'SUITE'}]}];
vm.selectedPropertyCode='Macau';vm.selectedRoomTypeCodes=['SUITE'];
vm.buildCalendar();
const first=vm.loadAvailabilityData();
const second=vm.loadAvailabilityData();
const date=vm.getCalendarDateRange().startDate;
pending[1]({success:true,data:[{date,roomType:'SUITE',roomCount:2}]});
await second;
pending[0]({success:true,data:[{date,roomType:'SUITE',roomCount:9}]});
await first;
assert.equal(vm.availabilityMap[date+'_SUITE'].count,2);
});
test('OTHER request sends real hotel codes and deduplicated room types',()=>{
const {vm}=setup();
vm.propertyList=[{code:'OTHER',otherProperties:[
{code:'A',roomTypes:[{code:'SUITE'}]},{code:'B',roomTypes:[{code:'SUITE'}]}
]}];
vm.selectedPropertyCode='OTHER';vm.selectedOtherPropertyCodes=['A','B'];
assert.deepEqual(vm.buildAvailabilityParams({startDate:'2026-09-01',endDate:'2026-09-02'}),
{Property:'A,B',RoomType:'SUITE',StartDate:'2026-09-01',EndDate:'2026-09-02'});
});
test('room selection cap retains existing selection',()=>{
const {vm}=setup();
vm.maxSelectedRoomTypes=1;vm.selectedRoomTypeCodes=['A'];
vm.toggleRoomType('B');
assert.deepEqual(vm.selectedRoomTypeCodes,['A']);
});
test('login remains launch page and calendar/settings labels have translations',async()=>{
const raw=await readFile(new URL('../pages.json',import.meta.url),'utf8');
const routes=JSON.parse(raw.replace(/^\s*\/\/.*$/gm,''));
assert.equal(routes.pages[0].path,'pages/login/login');
assert.deepEqual(routes.tabBar.list.map(t=>t.pagePath),[
'pages/dashboard/dashboard','salessupport/todoList/todoList','pages/findPlayer/findPlayer',
'pages/roomCalendar/roomCalendar','pages/setting/setting']);
assert.equal(routes.tabBar.list[3].visible,false);
for(const locale of ['en','zh-Hant']){
const messages=JSON.parse(await readFile(new URL('../locale/'+locale+'.json',import.meta.url),'utf8'));
assert.ok(messages['roomCalendar.title']);
assert.ok(messages['roomCalendar.maxSelectedRoomTypes']);
}
});
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const source = await readFile(new URL('../pages/dashboard/dashboard.vue',import.meta.url),'utf8');
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import[\s\S]*?from\s+['"][^'"]+['"];?[^\n]*/g,'').replace('export default','return');
function setup(data) {
let requests=0;
const page = new Function('mgmHeader','uni','getTodoDashboard',script)({},
{showLoading(){},hideLoading(){}},async()=>{requests++;return typeof data === 'function' ? data() : {success:true,data};});
const vm={$t:k=>k};
Object.assign(vm,page.data.call(vm),{userData:{userId:'80001'}});
for(const [key,fn] of Object.entries(page.methods)) vm[key]=fn.bind(vm);
return {vm,requests:()=>requests};
}
test('Dashboard request accepts both response casings and sets IM totals and data date',async()=>{
for(const pascal of [false,true]) {
const records=[{currentValue:0,itemName:'MF Turnover',userSegment:'IM',type:'Achievement',targetValue:100},
{itemName:'2026-09-09',type:'DataAsOf'}];
const data=pascal?{Records:records.map(r=>Object.fromEntries(Object.entries(r).map(([k,v])=>[k[0].toUpperCase()+k.slice(1),v])))}:{records};
const {vm,requests}=setup(data);
await vm.getTodoDashboardByuserId();
assert.equal(requests(),1);
assert.equal(vm.showDashboardView,true);
assert.equal(vm.userSegment,'IM');
assert.equal(vm.MFValue,0);
assert.equal(vm.dataAsOf,'2026-09-09');
}
});
test('Dashboard shows only populated sections and preserves IM turnover-only Achievement',async()=>{
for(const [records,achievement,kpi] of [
[[{type:'KPI',itemName:'Rate'}],false,true],
[[{type:'Achievement',itemName:'Target'}],true,false],
[[{type:'Achievement',itemName:'MF Turnover',userSegment:'IM',currentValue:0}],true,false]
]) {
const {vm}=setup({records}); await vm.getTodoDashboardByuserId();
assert.equal(vm.showAchievement,achievement); assert.equal(vm.showKPI,kpi);
}
});
test('Dashboard empty refresh clears old data and hides both sections',async()=>{
const data={records:[{type:'Achievement',itemName:'MF Turnover',userSegment:'IM',currentValue:99}]};
const {vm}=setup(data); await vm.getTodoDashboardByuserId();
data.records=[]; await vm.getTodoDashboardByuserId();
assert.deepEqual(vm.dashboardData,[]); assert.equal(vm.MFValue,'0');
assert.equal(vm.showDashboardView,false); assert.equal(vm.showAchievement,false); assert.equal(vm.showKPI,false);
});
test('Dashboard failed refresh hides previously displayed data',async()=>{
let fail=false;
const {vm}=setup(()=>{if(fail)throw Error('network');return {success:true,data:{records:[{type:'KPI',itemName:'Rate'}]}};});
await vm.getTodoDashboardByuserId(); fail=true; await vm.getTodoDashboardByuserId();
assert.equal(vm.showDashboardView,false); assert.equal(vm.showKPI,false); assert.equal(vm.showAchievement,false);
});
test('Dashboard normalization preserves camelCase zero over PascalCase fallback',()=>{
const {vm}=setup({});
const record={currentValue:0,CurrentValue:99,targetValue:0,TargetValue:100};
const normalized=vm.normalizeDashboardRecord(record);
assert.equal(normalized.currentValue,0);
assert.equal(normalized.targetValue,0);
assert.equal(record.CurrentValue,99);
});
+76
View File
@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {createRequire} from 'node:module';
const require = createRequire(import.meta.url);
const dayjs = require('../uni_modules/uview-ui/libs/util/dayjs.js');
const source = await readFile(new URL('../booking/playerRoomCalendar/playerRoomCalendar.vue', import.meta.url), 'utf8');
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import[\s\S]*?from\s+['"][^'"]+['"];?/g, '').replace('export default', 'return');
const utility = await readFile(new URL('../common/roomCalendarUi.js', import.meta.url), 'utf8');
const ui = await import('data:text/javascript;base64,' + Buffer.from(utility).toString('base64'));
function setup({permission=true, player={playerId:'80001'}, metadata, response}={}) {
const calls = [], toasts = [];
const user = {canViewCalendar:permission};
const uni = {getStorageSync:key=>key==='userData'?user:player,
showLoading(){},hideLoading(){},$u:{toast:m=>toasts.push(m)}};
const page = new Function('uni','dayjs','buildCalendarDateRange','getTodayWeekId',
'mapRoomCalendarQualificationRecords','getPropertiesRoomTypes','getPlayerRoomQualification',
'requestCalendarSetting',script)(uni,dayjs,ui.buildCalendarDateRange,ui.getTodayWeekId,
ui.mapRoomCalendarQualificationRecords,
async()=>{calls.push('metadata'); return metadata ?? {propertyList:[
{code:'Macau',name:'Macau',roomTypes:[{code:'SUITE',label:'SUITE'}],defaultRoomTypes:['SUITE']}
]};},
async p=>{calls.push(p);return response ?? {success:true,data:[]};},
async()=>{calls.push('settings');return {data:[]};});
const vm = {$t:k=>k,$nextTick:fn=>fn()};
Object.assign(vm,page.data.call(vm),{playerData:player,userData:user});
for (const [k,fn] of Object.entries(page.methods)) vm[k]=fn.bind(vm);
for (const [k,fn] of Object.entries(page.computed)) Object.defineProperty(vm,k,{get:fn.bind(vm)});
return {vm,page,calls,toasts};
}
test('denied direct entry and refresh make no requests',async()=>{
for (const permission of [false,undefined,'true']) {
const {vm,calls}=setup({permission:permission ?? null});
await vm.loadPropertiesRoomTypes(); vm.refreshCalendar(); await vm.loadQualificationData();
assert.deepEqual(calls,[]); assert.equal(vm.calendarNoPermission,true);
}
});
test('missing player cannot accidentally request the general Calendar',async()=>{
const {vm,calls}=setup({player:null});
await vm.loadPropertiesRoomTypes(); await vm.loadQualificationData();
assert.deepEqual(calls,[]); assert.equal(vm.calendarReady,false);
});
test('player query carries current player, property, room type and date range',async()=>{
const {vm,calls}=setup(); await vm.loadPropertiesRoomTypes();
const query=calls.find(c=>typeof c==='object');
assert.equal(query.PlayerId,'80001'); assert.equal(query.Property,'Macau');
assert.equal(query.RoomType,'SUITE');
assert.match(query.StartDate,/^\d{4}-\d{2}-\d{2}$/);
assert.ok(query.EndDate>=query.StartDate); assert.equal(vm.calendarReady,true);
});
test('metadata denial and empty configuration do not call player query',async()=>{
for (const metadata of [{success:false,code:'NO_PERMISSION'},{propertyList:[]}]) {
const {vm,calls}=setup({metadata}); await vm.loadPropertiesRoomTypes();
assert.deepEqual(calls,['settings','metadata']); assert.equal(vm.calendarReady,false);
}
});
test('both denial response formats remain denied after initial load completes',async()=>{
for (const key of ['code','errorCode']) {
const {vm}=setup({response:{success:false,[key]:'NO_PERMISSION'}});
await vm.loadPropertiesRoomTypes();
assert.equal(vm.calendarReady,false); assert.equal(vm.calendarNoPermission,true);
assert.deepEqual(vm.qualificationMap,{});
}
});
test('booking entry rechecks permission before navigation',async()=>{
const booking=await readFile(new URL('../booking/bookingRecord/bookingRecord.vue',import.meta.url),'utf8');
const method=booking.match(/navToPlayerRoomCalendar\(\)\{([\s\S]*?)\n\s*\},/)[1];
const visits=[];
for (const permission of [false,true]) {
new Function('uni',method).call({navTo:(...args)=>visits.push(args)},
{getStorageSync:()=>({canViewCalendar:permission})});
}
assert.deepEqual(visits,[['/booking/playerRoomCalendar/playerRoomCalendar',0,false]]);
assert.match(booking, /<image\s+v-if="userData && userData\.canViewCalendar === true"\s+@click="navToPlayerRoomCalendar"[^>]*><\/image>\s*<image\s+v-else\s+@click="getDataByPage"[^>]*><\/image>/);
});
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const root = new URL('../', import.meta.url);
const app = fs.readFileSync(new URL('App.vue', root), 'utf8');
const method = app.slice(app.indexOf('async writeLog('), app.indexOf('async logout(){'));
test('actual APP log method routes by token and preserves debugMode gate', async () => {
for (const token of ['', null, undefined, 'valid-token']) {
const calls = [];
const scope = {
uni: {getStorageSync: key => key === 'token' ? token : {debugMode: true}},
writeDebugLog: async value => calls.push(['private', value]),
writePublicDebugLog: async value => calls.push(['public', value]), console,
};
const {writeLog} = vm.runInNewContext(`({${method}})`, scope);
await writeLog('80001', 'LOGIN', 'txn', 'description', null, null, 1, false);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], token ? 'private' : 'public');
assert.equal(calls[0][1].eventCode, 'LOGIN');
scope.uni.getStorageSync = key => key === 'token' ? token : {debugMode: false};
await writeLog('80001', 'LOGIN', 'txn', 'description', null, null, 1, false);
assert.equal(calls.length, 1);
}
});
test('API wrappers use public bootstrap routes and preserve refresh protocol', async () => {
const calls = [];
const scope = {uni: {$u: {http: {post: (...args) => calls.push(args)}}}};
function load(file, names) {
const source = fs.readFileSync(new URL(file, root), 'utf8').replaceAll('export const ', 'const ');
return vm.runInNewContext(`${source}\n;({${names.join(',')}})`, scope);
}
const common = load('api/commonApi.js', ['getSettings', 'writeDebugLog', 'writePublicDebugLog', 'refreshToken']);
const config = load('api/configApi.js', ['getCfgSettingByKey']);
const params = {keyName: 'MACAU_STRING'};
const options = {custom: {test: true}};
common.getSettings(params, options); common.writeDebugLog(params, options);
common.writePublicDebugLog(params, options); config.getCfgSettingByKey(params, options);
common.refreshToken(params, options);
assert.deepEqual(calls.map(call => call[0]), [
'/system/sys_setting/get_public_login_settings', '/system/write_log', '/system/write_public_log',
'/web/setting/get_public_setting_by_key', '/app/refresh_token?isRenewRefreshToken=false',
]);
for (const call of calls) { assert.equal(call[1], params); assert.equal(call[2], options); }
});
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source=fs.readFileSync(new URL('../common/request.js',import.meta.url),'utf8').replace(/^import .*$/mg,'');
function harness(refresh) {
const storage=new Map([['token','old'],['refreshToken','refresh'],['clientId','client']]);
let failure, retried=0, redirects=0;
const context={module:{exports:{}},console:{log(){}},dayjs:()=>({unix:()=>123}),refreshToken:refresh,
uni:{getStorageSync:k=>storage.get(k),setStorageSync:(k,v)=>storage.set(k,v),removeStorageSync:k=>storage.delete(k),
redirectTo:()=>redirects++, $u:{toast(){},http:{request:async()=>{retried++;return 'retried';},
interceptors:{request:{use(){}},response:{use:(ok,bad)=>failure=bad}}}}}};
vm.runInNewContext(source,context); context.module.exports({});
return {storage, failure:e=>failure(e), counts:()=>({retried,redirects})};
}
const unauthorized=()=>({statusCode:401,config:{url:'/app/me',header:{},custom:{auth:true}}});
test('successful refresh retries once with new access credential', async()=>{
const h=harness(async()=>({success:true,accessToken:'new'})); const error=unauthorized();
assert.equal(await h.failure(error),'retried'); assert.equal(error.config.header.Authorization,'new');
assert.equal(h.storage.get('refreshToken'),'refresh');
await assert.rejects(h.failure(error)); assert.equal(h.counts().retried,1);
});
test('failed, malformed or missing refresh stops original request',async()=>{
for(const mode of ['failure','malformed','missing']) {
let calls=0;
const h=harness(async()=>{calls++;if(mode==='failure')throw new Error('denied');return {success:false};});
if(mode==='missing')h.storage.delete('refreshToken');
await assert.rejects(h.failure(unauthorized()));
assert.equal(h.counts().retried,0);assert.equal(h.storage.has('token'),false);
assert.equal(calls,mode==='missing'?0:1);
}
});
+98
View File
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const source=await readFile(new URL('../salessupport/todoList/todoList.vue',import.meta.url),'utf8');
const script=source.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import[\s\S]*?from\s+['"][^'"]+['"];?[^\n]*/g,'').replace('export default','return');
const utility=await readFile(new URL('../common/normalizeApiKeys.js',import.meta.url),'utf8');
const {normalizeApiKeys}=await import('data:text/javascript;base64,'+Buffer.from(utility).toString('base64'));
function setup({list=async()=>({success:true,data:{records:[]}}),detail=async()=>({success:true,data:{}})}={}) {
const notices=[];
const page=new Function('mgmHeader','SimpleWatermark','uni','normalizeApiKeys','getTodoList','getTodoDetail','setTimeout',script)(
{},{},{showLoading(){},hideLoading(){},showToast:v=>notices.push(v)},normalizeApiKeys,list,detail,fn=>fn());
const vm={$t:k=>k,currentTask:null,todoListRequestSeq:0,filterType:'IM',currentIndex:0,
startDate:'2026-09-09',endDate:'2026-09-10',calendarSave:[],listData:[]};
for(const [k,fn] of Object.entries(page.methods)) vm[k]=fn.bind(vm);
for(const [k,fn] of Object.entries(page.computed)) Object.defineProperty(vm,k,{get:fn.bind(vm)});
vm.formatDate=v=>v; vm.checkCalendarSave=()=>{};
return {vm,page,notices};
}
const rows=id=>({success:true,data:{records:[{date:'2026-09-09',dateRecords:[{playerId:id,eta:'13:45'}]}]}});
test('older success and error cannot overwrite latest Todo response',async()=>{
for(const rejectOld of [false,true]) {
const pending=[];
const {vm}=setup({list:()=>new Promise((resolve,reject)=>pending.push({resolve,reject}))});
const old=vm.getTodoListByData(); const latest=vm.getTodoListByData();
pending[1].resolve(rows('new')); await latest;
if(rejectOld) pending[0].reject(new Error('old failure')); else pending[0].resolve(rows('old'));
await old;
assert.equal(vm.listData[0].dateRecords[0].playerId,'new');
assert.equal(vm.listData[0].dateRecords[0].eta,'13:45');
assert.equal(vm.showTodoListView,true);
}
});
test('empty success and incomplete error response have distinct messages',async()=>{
for(const [response,message] of [[{success:true,data:{records:[]}},'common.noRecord'],
[{success:false},'todo.returnError'],[{success:false,code:'NO RECORD'},'common.noRecord']]) {
const {vm}=setup({list:async()=>response}); await vm.getTodoListByData();
assert.equal(vm.dataNull,message); assert.equal(vm.showTodoListView,false);
}
});
test('native Error without errMsg is handled',async()=>{
const {vm}=setup({list:async()=>{throw new Error('network');}});
await vm.getTodoListByData(); assert.equal(vm.dataNull,'todo.returnError');
});
test('detail normalizes IRC balance and nested Offer fields in both casings',async()=>{
for(const data of [{IRCProgramFundBalance:'0',Descriptions:[{ItemRecordType:'Offer'}]},
{iRCProgramFundBalance:'0',descriptions:[{itemRecordType:'Offer'}]}]) {
const {vm}=setup({detail:async()=>({success:true,data})});
const record={playerId:'80001',actionDate:'2026-09-09',arrowName:'arrow-down'};
await vm.fetchTodoDetail(record);
assert.equal(record.detailData.iRCProgramFundBalance,'0');
assert.equal(record.showOffer,true); assert.equal(record.showDetail,true);
assert.equal(record.isProcessing,false);
}
});
test('ETA label still uses Daylight eta field',()=>{
const {vm,page}=setup();
assert.equal(page.computed.getETALable.call(vm)({eta:'13:45',eTA:'09:00'}),'13:45');
});
test('Todo formatters accept API string amounts, hours and ratios',()=>{
const {vm}=setup();
assert.equal(vm.formatNumber('3000000000'),'3,000,000,000');
assert.equal(vm.formatNumber('12.75'),'12.75');
assert.equal(vm.formatNumber('0'),'0');assert.equal(vm.formatNumber(null),'0');
assert.equal(vm.formarHour('1.5'),'1.5 hours');
assert.equal(vm.formarHour('0'),'0 hour');
assert.equal(vm.getPercentFormat('0.25'),'25%');
assert.equal(vm.getPercentFormat('0'),'0%');
});
test('Sharer renderer preserves trailing room status and notes and only renders Y icon',()=>{
const {vm}=setup();
for(const flag of ['Y','N']) {
const parts=vm.getRoomDescriptionParts({itemRecordType:'Room',itemRecordDescription:`Conf#123 [Rm Ready] | Suite | Sharer:${flag} | Note`});
assert.equal(parts.filter(p=>p.type==='sharerIcon').length,flag==='Y'?1:0);
const text=parts.filter(p=>p.type==='text').map(p=>p.text).join('');
assert.ok(text.includes('Conf#123 [Rm Ready]'));assert.ok(text.includes('Note'));
assert.ok(!text.includes('Sharer:'));
}
assert.equal(vm.getItemShowSharerIcon('Conf#123 | Sharer:N | Note'),false);
assert.deepEqual(vm.getDescriptionWithSharerIconParts(null),[]);
});
test('summary room markers use row flags only on first line and Limo warning excludes Inhouse',()=>{
const conditions=[...source.matchAll(/v-show="([^"]+)"/g)].map(m=>m[1]);
for(const field of ['isQueue','roomReady']) {
const expression=conditions.find(c=>c.includes(`rowRecord.${field}`));
assert.ok(expression);
const evaluate=new Function('index','rowRecord',`return ${expression}`);
assert.equal(evaluate(0,{[field]:true}),true);
assert.equal(evaluate(1,{[field]:true}),false);
assert.equal(evaluate(0,{[field]:false}),false);
}
const warning=conditions.find(c=>c.includes("hasLimoRecord =='-1'"));
const evaluate=new Function('index','currentIndex','rowRecord',`return ${warning}`);
assert.equal(evaluate(0,0,{hasLimoRecord:-1}),true);
assert.equal(evaluate(0,2,{hasLimoRecord:-1}),false);
assert.equal(evaluate(1,0,{hasLimoRecord:-1}),false);
assert.equal(evaluate(0,0,{hasLimoRecord:1}),false);
});