This commit is contained in:
oneao committed 2026-10-15 22:47:42 +08:00
1 parent bf9dca19de
commit e7c84a82a2
31 files changed
+4063 -2362

No files matched your search

+466 -31
View File
@@ -297,6 +297,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const before = await rowHeights(page, '[data-testid="table-wrap"]')
// 双击进编辑(表格是「单击选中、进编辑另有其键」的两态模型,见 startEdit 的说明)
await (await cellByTitle(page, '[data-testid="table-wrap"]', '状态')).click()
const select = wrap.locator('.g3-table__cell-editor-root.g3-select').first()
await expect(select).toBeVisible()
@@ -484,6 +485,105 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
await expect(row.locator('td').nth(remarkIdx).locator('.is-editor-warning')).toHaveCount(1)
})
/**
* 失败原因必须能到达用户。
*
* 背景:`resolveEditorStatus` 早先只取校验结果字符串的 `.length` 判等级、把内容丢掉 ——
* 业务在 `validate` 里写的失败原因(「金额必须大于 0」)因此没有任何出口,
* 用户只知道「这格红了」,不知道要改成什么。
*
* 这条从「内容节点上的 data 属性」一路验到「浮层真的弹出来」:属性没挂、
* 委托没接上、浮层被表格的 `overflow` 裁掉、文本没绑 —— 任何一环被改断都会红。
*/
test('失败原因浮层:hover 出错格弹出 validate 的原因,移到普通格即收', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const errSpan = rows.nth(1).locator('td[data-col-key="amount"] .g3-table__cell-display')
const warnSpan = rows.nth(1).locator('td[data-col-key="remark"] .g3-table__cell-display')
// 原因挂在内容节点上(不是 td —— 按行变化的值打不到 td 上,见 editable.ts 的说明)
await expect(errSpan).toHaveAttribute('data-validate-message', '金额必须大于 0')
await expect(warnSpan).toHaveAttribute('data-validate-message', '建议填写备注')
// 常态下浮层压根不在 DOM 里(`v-if`)—— 这是「全表共用一个、常态零成本」的直接证据
const tip = page.locator('.g3-table__cell-tip')
await expect(tip).toHaveCount(0)
await errSpan.hover()
await expect(tip).toBeVisible()
await expect(tip).toHaveText('金额必须大于 0')
// 移到同一行的普通格(第 1 行金额是正常值):浮层立刻收掉 ——
// 不能一直挂着上一格的原因
await rows.nth(0).locator('td[data-col-key="amount"]').hover()
await expect(tip).toHaveCount(0)
/**
* warning 走同一条通道,且**悬停格子(td)而不是那个 span**:
* 备注在那一行是空值,展示 span 宽度为 0,鼠标落在 td 的 padding 上 ——
* 这正是「按事件目标的祖先链找消息节点」会静默失效的位置。
* 委托改为以格子为单位反查后,这里必须能弹出来。
*/
await rows.nth(1).locator('td[data-col-key="remark"]').hover()
await expect(tip).toHaveText('建议填写备注')
})
/**
* 数值精度(`editor.precision`)的完整行为:按精度格式化展示、超精度只标红、
* 且**不改数据**。
*
* playground 的「金额」列配了 `precision: 2` 与 `format: { thousands: true, prefix: '¥' }`。
* 这里刻意用**动态输入**造超精度值,而不是往宿主的静态数据里塞一行 ——
* 静态数据一改就会连带影响同页其它「error 格计数」的断言,动态输入只在本次用例内生效。
*/
test('数值精度:按精度格式化展示;输多了只标红、值原样保留', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="amount"]')
// ① 展示:按 precision 补足到两位小数,叠加 format 的千分位与 ¥ 前缀
await expect(cell.locator('.g3-table__cell-display')).toHaveText('¥12,000.00')
/**
* ② 输入一个超精度的值,再点**另一个数据格**让它失焦提交。
*
* 这里不能点表头:编辑态的退出是由单元格选区变化驱动的,点表头不会退出编辑 ——
* 那样这一格还挂着编辑器,下面针对「展示态 span」的断言会直接找不到元素。
*/
await cell.click()
await cell.locator('input').fill('1.2345')
await wrap.locator('.stk-tbody-main tr').nth(2).locator('td[data-col-key="amount"]').click()
await page.waitForTimeout(200)
// ③ 标红,原因是**组件内置**的(业务在这一列没写任何与精度有关的校验)
await expect(cell.locator('.g3-table__cell-display')).toHaveClass(/is-editor-error/)
await cell.hover()
await expect(page.locator('.g3-table__cell-tip')).toHaveText('最多 2 位小数')
/**
* ④ 超精度时**不做格式化展示**:按两位格式化成 `¥1.23` 会让屏幕看起来完全合规、
* 数据却不合规,用户永远发现不了自己错了 —— 所以这里必须是原样的 `1.2345`。
*/
await expect(cell.locator('.g3-table__cell-display')).toHaveText('1.2345')
// ⑤ 值也没被四舍五入:再点开编辑,输入框里还是用户敲的那串
await cell.click()
await expect(cell.locator('input')).toHaveValue('1.2345')
/**
* ⑥ 编辑器的文字对齐**跟随列**(金额列是 `align: 'right'`)。
*
* 表单控件的 UA 样式把继承来的 `text-align` 覆盖成 `start`,不显式接上就会
* 「展示态靠右、一点进编辑就跳到左边」,光标也跟着跑到最左 —— 进出编辑的位移很扎眼。
*/
await expect(cell.locator('input')).toHaveCSS('text-align', 'right')
})
/**
* 编辑态让位:正在编辑的校验格**不铺校验底色**,底色回到基色。
*
@@ -603,6 +703,43 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
* sticky 几何就会失效(表头跟着滚、分隔线错位叠线)。
*/
test.describe('G3Table 布局:表头固定与分隔线', () => {
/**
* 按**顶层**逗号切分 CSS 列表值(`background-image` / `-size` / `-position` 都是这种)。
*
* ⚠ 不要写成 `split(/,(?![^(]*\))/)`:那种写法靠「逗号后面是否紧跟一个 `)`」判断,
* 一旦逗号后面是 `rgba(` 这种带括号的值就失灵 —— 括号把 `[^(]*` 截断,负向前瞻
* 随之成立,于是渐变**内部的**逗号(如 `linear-gradient(rgba(0,0,0,.08), rgba(0,0,0,.08))`
* 两个色标之间那个)被当成层分隔符,一层被切成两段(实测把「两条线」数成三条)。
* 括号配对计数才是可靠判据。
*/
function splitCssLayers(value: string): string[] {
const out: string[] = []
let depth = 0
let start = 0
for (let i = 0; i < value.length; i++) {
const ch = value[i]
if (ch === '(') depth++
else if (ch === ')') depth--
else if (ch === ',' && depth === 0) {
out.push(value.slice(start, i).trim())
start = i + 1
}
}
out.push(value.slice(start).trim())
return out.filter(Boolean)
}
/** 数「真的画得出颜色」的背景层:rgb(...) 或 alpha > 0 的 rgba(...) */
async function visibleLineCount(locator: import('@playwright/test').Locator) {
const bg = await locator.evaluate((el) => getComputedStyle(el).backgroundImage)
return splitCssLayers(bg).filter((layer) =>
[...layer.matchAll(/rgba?\(([^)]+)\)/g)].some((m) => {
const parts = m[1].split(',').map((s) => Number(s.trim()))
return parts.length < 4 || parts[3] > 0
}),
).length
}
test('table 不被拉伸:.stk-table-main 是 flex:none(非 autoFitWidth)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
@@ -637,7 +774,7 @@ test.describe('G3Table 布局:表头固定与分隔线', () => {
expect(Math.abs(after!.y - before!.y)).toBeLessThan(2)
})
test('边框自绘:库只保留竖线,横线走渐变而不是真 border', async ({ page }) => {
test('边框自绘:表体只剩横线走渐变,而不是真 border', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
@@ -646,25 +783,22 @@ test.describe('G3Table 布局:表头固定与分隔线', () => {
const stk = wrap.locator('.stk-table').first()
await expect(stk).toHaveClass(/bordered-v/)
/** 数「非透明的背景渐变层数」——即这一格画了几条线(不看库内部怎么拼) */
const lineCount = (locator: import('@playwright/test').Locator) =>
locator.evaluate((el) =>
getComputedStyle(el)
.backgroundImage.split(/,(?![^(]*\))/)
.filter((layer) => /rgb\(\d+, \d+, \d+\)/.test(layer)).length,
)
// 横线:`'v'` 档把 --bg-border-bottom 清零(只剩竖线一条),组件按底座配方把它
// 拿回来 —— 所以这里应当数出**两条**。但它**不能**是真 border:格线一进盒模型,
// 单元格多 1px 就会被行内编辑器顶高(行高是虚拟滚动 translateY 的基准)。
// 表体的竖线已被覆盖层涂透明(--bg-border-right / --bg-border-left),所以表体
// 每一格只剩**一条**可见渐变:组件按底座配方拿回来的那条横线。
// (表头是例外 —— 列界画在 th 的 background-image 上,见下一条用例。)
const td = wrap.locator('.stk-tbody-main td').first()
const normalLines = await lineCount(td)
expect(normalLines).toBeGreaterThan(1)
expect(await td.evaluate((el) => getComputedStyle(el).borderBottomWidth)).toBe('0px')
const normalLines = await visibleLineCount(td)
expect(normalLines).toBe(1)
// 竖线:末列的外侧那条已被抹掉(归自绘外框管),所以比普通列**正好少一条**
// 末列不再「少一条」:表体竖线既然全关,末列与普通列的层数必须相等
// (旧断言是 normalLines - 1,那是在摘「最右列的外侧竖线」——该需求已不存在)
const lastTd = wrap.locator('.stk-tbody-main td:last-child').first()
expect(await lineCount(lastTd)).toBe(normalLines - 1)
expect(await visibleLineCount(lastTd)).toBe(normalLines)
// 横线**不能**是真 border:格线一进盒模型,单元格多 1px 就会被行内编辑器顶高
// (行高是虚拟滚动 translateY 的基准)。
expect(await td.evaluate((el) => getComputedStyle(el).borderBottomWidth)).toBe('0px')
expect(await td.evaluate((el) => getComputedStyle(el).borderRightWidth)).toBe('0px')
// 行高没被格线动过(30 = 默认 rowHeight,也是虚拟滚动 translateY 的基准)
expect(
@@ -675,24 +809,47 @@ test.describe('G3Table 布局:表头固定与分隔线', () => {
).toBe(30)
})
test('库自绘边框:单元格带可见的横竖渐变线', async ({ page }) => {
test('纵向格线:表体 td 无竖线,表头 th 的列界不占满高度且颜色更轻', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
// 库的 .bordered th,td 用 background-image 画 --bg-border-right / --bg-border-bottom。
// 这里只断言「至少有一条可见渐变线」,不锁具体层数——那是库的内部实现。
const layers = await wrap
.locator('.stk-tbody-main td')
.first()
.evaluate((el) => {
const bg = getComputedStyle(el).backgroundImage
return bg
.split(/,(?![^(]*\))/)
.map((s) => s.trim())
.filter((s) => /rgb\(\d+, \d+, \d+\)/.test(s) && !/rgba\(0, 0, 0, 0\)/.test(s)).length
})
expect(layers).toBeGreaterThan(0)
// ── 表体:一条竖线都没有(全局涂透明),只剩横线那一条。
// 读「变量」是因为变量就是库的输入、语义最直接,不会随库内部怎么拼 image 而失效。
// ⚠ 断言里**不能**出现 `var(--border-color)` 这类字面量:自定义属性的计算值会把
// 里面的 var() 全部替换掉(规范如此),拿到的已经是 `#d9d9d9`。
const td = wrap.locator('.stk-tbody-main td').first()
const tdVars = await td.evaluate((el) => {
const cs = getComputedStyle(el)
return {
right: cs.getPropertyValue('--bg-border-right').trim(),
left: cs.getPropertyValue('--bg-border-left').trim(),
}
})
expect(tdVars.right).toContain('transparent')
expect(tdVars.left).toContain('transparent')
expect(tdVars.right).not.toMatch(/rgb\(|#[0-9a-f]{3}/i)
expect(await visibleLineCount(td)).toBe(1)
// ── 表头:列界由组件自己接管 background-image 画(不是库的 --bg-border-right),
// 两层可见线 = 短竖线 + 横线。
const th = wrap.locator('thead th').first()
const thPaint = await th.evaluate((el) => {
const cs = getComputedStyle(el)
return {
image: cs.backgroundImage,
size: cs.backgroundSize,
position: cs.backgroundPosition,
}
})
expect(await visibleLineCount(th)).toBe(2)
// 不占满:竖线那层的高度是 calc(100% - 8px) —— 上下各留 4px 的呼吸位
expect(thPaint.size.split(',')[0].trim()).toBe('1px calc(100% - 8px)')
expect(thPaint.position.split(',')[0].trim()).toBe('100% 4px')
// 更轻:竖线是 8% 的黑(半透明),不是格线那种不透明色
expect(thPaint.image).toMatch(/rgba\(0,\s*0,\s*0,\s*0\.08\)/)
})
})
@@ -1153,6 +1310,284 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
* 两个宿主覆盖两条 CSS 分支:`table-stripe-wrap`(2 行,纵向虚拟关闭 → `nth-child(even)`)
* 与 `table-big-wrap`(5000 行 → `vt-on`,库改取 `nth-child(odd)`)。
*/
/**
* 日期编辑器(`editor.type = 'date'`)。
*
* 宿主是 playground 的 `table-date-wrap` —— 单独一张小表:`table-wrap` 的列序被 Tab
* 跳格用例逐格锁着(amount → status → enabled → remark 的顺位),往它里面加列会连带
* 改一串断言。
*
* 列:订舱号(只读) / 开航日(date,默认 `YYYY-MM-DD`,配了必填校验) /
* 截单日(date,`'YYYY-MM-DD HH:mm'`,用来覆盖「带时间精度」的配置形态)。
*/
/**
* 方向键的分工(编辑器 vs 表格)。
*
* 本组件的交互是**单击即进入行编辑**(点一格就能直接打字,少一次点击)。在这个前提下,
* 方向键必须显式分工 —— 否则会全部被输入框吃掉,人在表格里根本走不动:
*
* · **左右** → 交给输入框移动光标 / `Shift+左右` 选中文本(「想改中间一位」的刚需);
* · **上下** → 让给表格导航,切到上 / 下一行的同一列
* (单行输入框里上下键**没有任何原生用途**,让出去是零损失);
* · `Shift+上下` → 仍归输入框(原生语义是「选中到首尾」);
* · `textarea` 里的上下 → 也归输入框(要跨行移动光标)。
*
* 宿主是 playground 的 `table-keys-wrap`:text / number / textarea 三列。
*/
test.describe('G3Table 方向键分工:左右归输入框、上下给导航', () => {
test('单击即进入编辑:挂上编辑器并自动聚焦', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
await cell.click()
await expect(cell.locator('input')).toHaveValue('K-001')
await expect(cell.locator('input')).toBeFocused()
})
test('上下键:切到上 / 下一行的同一列(并且进编辑,可以直接接着改)', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const first = rows.nth(0).locator('td[data-col-key="code"]')
await first.click()
await page.keyboard.press('ArrowDown')
await expect(rows.nth(1).locator('td[data-col-key="code"] input')).toBeFocused()
// 反向同样有效(连续按能一直在这一列上下走,手不离开键盘)
await page.keyboard.press('ArrowUp')
await expect(first.locator('input')).toBeFocused()
})
test('左右键:在输入框内移动光标,不切格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="qty"]')
await cell.click()
const input = cell.locator('input')
await input.fill('12345')
// 不假设 fill 之后光标落在哪,只断言「按一次左键,光标左移一位」
const before = await input.evaluate((el) => (el as HTMLInputElement).selectionStart)
await input.press('ArrowLeft')
const after = await input.evaluate((el) => (el as HTMLInputElement).selectionStart)
expect(after).toBe((before ?? 0) - 1)
await expect(input).toBeFocused()
// 还在同一格:左右键没有冒到表格导航上
await expect(cell.locator('input')).toHaveCount(1)
})
test('Shift+左右:留给输入框选中文本,不切格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
await cell.click()
const input = cell.locator('input')
// 进入编辑时编辑器会**全选**内容(为「直接输入即替换」服务),所以先记下初始选区长度
const before = await input.evaluate((el) => {
const node = el as HTMLInputElement
return (node.selectionEnd ?? 0) - (node.selectionStart ?? 0)
})
await input.press('Shift+ArrowLeft')
await input.press('Shift+ArrowLeft')
// 结果是「选区缩小了两个字符」,而不是「跳到左边那一格」
const after = await input.evaluate((el) => {
const node = el as HTMLInputElement
return (node.selectionEnd ?? 0) - (node.selectionStart ?? 0)
})
expect(after).toBe(before - 2)
await expect(input).toBeFocused()
})
test('textarea:上下键留给它跨行移动光标,不切格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="note"]')
await cell.click()
const textarea = cell.locator('textarea')
await textarea.fill('第一行')
await textarea.press('Enter')
await textarea.type('第二行')
// 光标在第二行 → 按上键应当回到第一行(列号保持),而不是跳到上一行的格子里
await textarea.press('ArrowUp')
await expect(textarea).toBeFocused()
const caret = await textarea.evaluate((el) => (el as HTMLTextAreaElement).selectionStart)
// "第一行\n第二行":第一行末尾是索引 3("第一行" 三个字)
expect(caret).toBe(3)
})
})
/**
* 键盘行为:Enter 与多行编辑器。
*
* · Enter(单行)→ 确认 + 下移到下一行同列(Excel / Sheets / AG Grid / Handsontable 的通行约定);
* · Enter(多行)→ 换行(浏览器原生;多行与单行共用一段 onKeydown,早先没按类型分流,
* 按回车会把整段文本全选掉、根本换不了行)。
*
* 方向键的分工(左右归输入框、上下给导航)在另一个 describe 里;宿主相同 ——
* `table-wrap` 的列序被 Tab 跳格用例逐格锁着,而且它里面没有多行编辑器,
* 锁不住「单行 / 多行 Enter 语义不同」这条区分。
*/
test.describe('G3Table 键盘行为:Enter 与多行编辑器', () => {
test('Enter(单行):确认并下移到下一行的同一列', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const firstCell = rows.nth(0).locator('td[data-col-key="code"]')
await firstCell.click()
await firstCell.locator('input').fill('K-999')
await firstCell.locator('input').press('Enter')
await page.waitForTimeout(300)
// 值**已提交**:旧行为是「全选文本、不提交」,改完按回车值还没落库。
// 断言整格文本而不是 `.cell-display` —— 这一列没配 validate/precision,
// 非编辑态走的是库的原生 td 渲染(连我们那个 span 都没有),这是有意为之的零开销路径。
await expect(firstCell).toHaveText('K-999')
// 编辑格落到下一行的**同一列**,且焦点已经在它里面(走的是改选区那条链路)
await expect(rows.nth(1).locator('td[data-col-key="code"] input')).toBeFocused()
})
test('Enter(多行):是换行,不是下移', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="note"]')
await cell.click()
const textarea = cell.locator('textarea')
await textarea.fill('第一行')
await textarea.press('Enter')
await textarea.type('第二行')
// 换行符真的进去了,而且**还停在同一格**(没有跳到下一行)
await expect(textarea).toHaveValue('第一行\n第二行')
await expect(textarea).toBeFocused()
})
})
test.describe('G3Table 日期编辑器(editor.type = date)', () => {
test('点日期格即弹日历,选中的日期写回字符串', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-date-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="etd"]')
// 展示态就是存进去的字符串,组件不做任何格式转换
await expect(cell.locator('.g3-table__cell-display')).toHaveText('2026-03-15')
/**
* ① 点格 → 自动聚焦 **且自动弹开日历面板**。
*
* 这是 `type: 'date'` 的核心交互:点格的那次点击落在 `td` 上,触发不到 DatePicker
* 自己的「点输入框才展开」,所以要由挂载钩子主动调它的 `openPanel()`
* (见 `utils/editable.ts` 的 openDatePanel)。手感与 ERP 里的日期格一致。
*/
await cell.click()
/**
* 焦点**落在面板里**(CalendarPanel 的日期格是 `<button>` 且带 tabindex),
* 这是日历组件的常规行为:一打开就能用键盘直接选日期。
* 所以这里不能断言「输入框持有焦点」—— 想手输要先点一下输入框(见下一条用例)。
*/
await expect(cell.locator('input')).toHaveCount(1)
const panel = page.locator('.g3-calendar')
await expect(panel).toBeVisible()
/**
* ② 面板里选一天 → 按 `format`(没配 valueFormat 时跟随它)写回字符串。
*
* 面板打开时定位在**当前值所在月份**,所以这一格能稳定点到同月的某一天;
* 空值格会落在「今天」那个月,不适合写死日期断言(那是另一条用例的事)。
*/
await panel.locator('.g3-calendar__cell[data-key="2026-03-20"]').click()
// 选完面板自动关闭;这一格**仍在编辑态**(选值不退出编辑),所以断言输入框的值
await expect(panel).toBeHidden()
await expect(cell.locator('input')).toHaveValue('2026-03-20')
// 切到别的格退出编辑 → 展示态显示的是刚写回行数据的字符串(证明值真的落了库)
await wrap.locator('.stk-tbody-main tr').nth(1).locator('td[data-col-key="etd"]').click()
await expect(cell.locator('.g3-table__cell-display')).toHaveText('2026-03-20')
})
test('必填校验与手输提交:空值是 error,手输失焦后写回', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-date-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const empty = rows.nth(1).locator('td[data-col-key="etd"]')
// 第 2 行开航日是空串 → 列上配的 validate 判它必填,进页面就是 error
await expect(empty.locator('.is-editor-error')).toHaveCount(1)
// 悬停那格能看到原因:日期列同样接上了统一的那套失败原因浮层
await empty.hover()
await expect(page.locator('.g3-table__cell-tip')).toHaveText('请选择开航日')
// 手输一个合法日期,再点**另一行的日期格**让它失焦提交(DatePicker 只在「确定的时刻」emit)
await empty.click()
await empty.locator('input').fill('2026-05-01')
/**
* ⚠️ 这里必须点**可编辑格**。点只读列(订舱号)不会让编辑器失焦 ——
* 库在 mousedown 阶段就把只读格的事件吞了,input 一直持有焦点,
* 表现是「输了值但没落库」(实测踩到)。
*/
await rows.nth(2).locator('td[data-col-key="etd"]').click()
await page.waitForTimeout(250)
await expect(empty.locator('.g3-table__cell-display')).toHaveText('2026-05-01')
// 值补上了,error 自然消失(校验是行数据的派生值)
await expect(empty.locator('.is-editor-error')).toHaveCount(0)
})
/**
* 手输非法日期。
*
* 这条锁的是「**值不丢弃**」这个决定:DatePicker 有意不为解析不出来的文本 emit
* (否则表单的 `v-model` 会被 `2026-13-45` 污染),表格侧在编辑器卸载时把它
* 原样写回行数据,再交给内置的日期校验标红 —— 与「金额输 0」是同一套立场:
* 值不合法不等于要抹掉它,而是要让人看见、并知道改成什么。
*/
test('手输非法日期:值原样保留、标红,悬停能看到原因', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-date-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const cell = rows.first().locator('td[data-col-key="etd"]')
await cell.click()
await cell.locator('input').fill('2026-13-45')
await rows.nth(2).locator('td[data-col-key="etd"]').click()
await page.waitForTimeout(300)
// 上一步点第 3 行会顺手弹出它自己的日历面板 —— 那是 Teleport 到 body 的浮层,
// 会盖住下面要 hover 的第一行。先按 Esc 收掉(只影响刚点开的那个面板)。
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
// 值原样保留(不是被丢弃成空、也不是回退成旧值),并进入 error 态
await expect(cell.locator('.g3-table__cell-display')).toHaveText('2026-13-45')
await expect(cell.locator('.is-editor-error')).toHaveCount(1)
// 原因走的是统一的那套浮层,日期列没有另做提示
await cell.hover()
await expect(page.locator('.g3-table__cell-tip')).toHaveText('日期格式不正确')
})
})
test.describe('G3Table 斑马纹与固定列', () => {
/**
* 取某一行**第一个单元格**(固定列/序号列)的底色与叠色情况。