audit-workshop-board-display.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. /** Read-only acceptance of the public board against the workshop deliverables. */
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs/promises';
  4. import path from 'node:path';
  5. import { pathToFileURL } from 'node:url';
  6. import { loadPublicBoard, publicBoardAction } from '../src/services/workshop-data.js';
  7. import { buildModel } from '../src/core/kfs/build.js';
  8. import { reportCenter } from '../src/core/library/reports.js';
  9. import { indexBoardMembers } from '../src/core/member-groups.js';
  10. const root = process.cwd();
  11. const work = path.join(root, 'work/workshop-board-acceptance-20261110');
  12. const out = path.join(root, 'docs/workshop-board-acceptance-20261110');
  13. await fs.mkdir(work, { recursive: true });
  14. await fs.mkdir(out, { recursive: true });
  15. const save = (filename, value) => fs.writeFile(filename, JSON.stringify(value, null, 2) + '\n');
  16. const endpoint = 'https://kangshifu.fmode.cn/api/functions';
  17. const appId = 'FKieRbIoGt_main';
  18. let raw, groups;
  19. if (process.argv.includes('--fetch')) {
  20. raw = await loadPublicBoard({ url: endpoint, appId, fetchImpl: fetch });
  21. groups = await publicBoardAction({ url: endpoint, appId, name: 'ksfGetPublicMemberGroups', params: {}, fetchImpl: fetch });
  22. await save(path.join(work, 'public-board.json'), raw);
  23. await save(path.join(work, 'public-groups.json'), groups);
  24. } else {
  25. raw = JSON.parse(await fs.readFile(path.join(work, 'public-board.json')));
  26. groups = JSON.parse(await fs.readFile(path.join(work, 'public-groups.json')));
  27. }
  28. const db = buildModel(raw);
  29. const center = reportCenter(db);
  30. const directory = indexBoardMembers(raw);
  31. const artifactEntries = center.entries.filter(e => e.kind === 'artifact');
  32. assert.deepEqual(artifactEntries.map(e => e.id).sort(), raw.artifact.map(a => a.objectId).sort());
  33. const groupId = row => groups.assignments[directory.memberId('artifact', row)] || '__ungrouped';
  34. const tally = key => raw.artifact.reduce((map, row) => {
  35. const value = row[key] || 'unspecified';
  36. map[value] = (map[value] || 0) + 1;
  37. return map;
  38. }, {});
  39. const live = raw.artifact.filter(a => a.dataMode === 'live');
  40. const audit = {
  41. schema: 'ksf.workshop.board-display-audit.v1', checkedAt: new Date().toISOString(),
  42. scope: 'public-read-only; enterprise onboarding deferred by user', businessWrites: 0,
  43. publicSnapshotAt: raw.loadedAt,
  44. counts: Object.fromEntries(['project', 'artifact', 'insight', 'source', 'session', 'opportunity'].map(k => [k, raw[k].length])),
  45. artifactTypes: tally('artifactType'), dataModes: tally('dataMode'), versions: tally('version'),
  46. publicArtifactIdsAllReachReportCenter: true,
  47. structuredReports: raw.artifact.filter(a => a.report).length,
  48. rawTextOnlyArtifacts: raw.artifact.filter(a => !a.report && a.rawText).length,
  49. groupStateRevision: groups.revision,
  50. groups: [...groups.groups, { id: '__ungrouped', name: '未分组' }].map(g => ({
  51. id: g.id, name: g.name,
  52. memberCount: db.members.filter(m => (groups.assignments[m.id] || '__ungrouped') === g.id).length,
  53. artifactCount: raw.artifact.filter(a => groupId(a) === g.id).length,
  54. liveArtifactCount: live.filter(a => groupId(a) === g.id).length,
  55. })),
  56. liveArtifactsWithoutGroup: live.filter(a => groupId(a) === '__ungrouped').map(a => a.objectId),
  57. publicRowsWithSeriesKey: raw.artifact.filter(a => a.reportSeriesKey).length,
  58. modelRowsRetainingVersion: db.artifacts.filter(a => Number.isInteger(a.version)).length,
  59. modelRowsRetainingDataMode: db.artifacts.filter(a => a.dataMode).length,
  60. findings: [
  61. { id: 'group-results', status: 'needs-change', detail: 'Shared member groups are readable; report lists have no group/workshop filter or group deliverable overview. Current live records are ungrouped.' },
  62. { id: 'version-and-course-mapping', status: 'needs-change', detail: 'Public rows carry version but the model discards it; public projection omits reportSeriesKey used by Skill taskKey. Chapter matching and latest-version selection cannot be established reliably.' },
  63. { id: 'seven-deliverables', status: 'needs-change', detail: 'Five generic report categories do not provide the seven workshop deliverables or their missing-item status. Two concept outputs and distinct VOC pools require explicit task/chapter mapping.' },
  64. { id: 'data-mode', status: 'needs-change', detail: 'Live/sample/unspecified modes are not retained in the artifact model or offered as list filters; existing mixed records must not be counted as a real group completion rate.' },
  65. { id: 'assessment-classification', status: 'needs-change', detail: 'assessment_report is mapped to the generic concept page, including the database acceptance report; this is not evidence of a product proposal.' },
  66. { id: 'independent-concept', status: 'needs-change', detail: 'The public board does not return KfConcept or formula-item details; retaining a project currentConceptRef does not display the independent concept record.' },
  67. ],
  68. productionDisplayAcceptancePassed: false,
  69. };
  70. if (process.argv.includes('--browser')) {
  71. const { chromium } = await import(pathToFileURL(path.join(root, 'work/workshop-manual-tools/node_modules/playwright/index.mjs')).href);
  72. const browser = await chromium.launch({ headless: true, executablePath: 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe' });
  73. const checks = [], errors = [], functionCalls = [], blockedActions = [];
  74. try {
  75. const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
  76. const page = await context.newPage();
  77. page.on('pageerror', e => errors.push(e.message));
  78. await page.route('**/api/functions', async route => {
  79. const req = route.request();
  80. if (req.method() !== 'POST') return route.continue();
  81. let name = '';
  82. try { name = req.postDataJSON().path; } catch { /* An unrecognized action is not part of this read-only audit. */ }
  83. if (!['/ksfGetPublicBoard', '/ksfGetPublicMemberGroups'].includes(name)) {
  84. blockedActions.push(name); return route.abort();
  85. }
  86. functionCalls.push(name); return route.continue();
  87. });
  88. assert.equal((await page.goto('https://kangshifu.fmode.cn/reports', { waitUntil: 'domcontentloaded' })).status(), 200);
  89. await page.locator('view-reports [data-types]').waitFor({ timeout: 45000 });
  90. const go = async (pathname, selector) => {
  91. await page.evaluate(async pathname => {
  92. const { navigate } = await import('/src/utils/router.js'); navigate(pathname);
  93. }, pathname);
  94. await page.locator(selector).first().waitFor({ timeout: 20000 });
  95. await page.evaluate(async () => { const app = document.querySelector('kf-app'); await app.updateComplete; });
  96. };
  97. for (const width of [1440, 390]) {
  98. await page.setViewportSize({ width, height: 1000 });
  99. for (const [pathname, selector] of [['/reports', 'view-reports [data-types]'], ['/members', 'view-members [data-member-group]'], ['/stages', 'view-stages'], ['/pool', 'view-pool']]) {
  100. await go(pathname, selector);
  101. const size = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }));
  102. checks.push({ kind: 'route', width, pathname, noPageOverflow: size.scroll <= size.width });
  103. if (pathname === '/members') {
  104. await page.waitForFunction(() => document.querySelector('kf-app')?.shadowRoot.querySelector('view-members')?._loading === false);
  105. await page.locator('view-members').getByRole('button', { name: /^G1/ }).click();
  106. const names = await page.locator('view-members [data-member-id]').evaluateAll(nodes => nodes.map(n => n.getAttribute('data-member-id')));
  107. const expected = db.members.filter(m => groups.assignments[m.id] === 'legacy-group:G1').map(m => m.id).sort();
  108. assert.deepEqual(names.sort(), expected);
  109. checks.push({ kind: 'member-group-filter', width, group: 'G1', members: names.length, matchesSharedAssignments: true });
  110. }
  111. await page.screenshot({ path: path.join(work, `${pathname.slice(1)}-${width}.png`), fullPage: true });
  112. }
  113. }
  114. await page.setViewportSize({ width: 1440, height: 1000 });
  115. const normalize = text => String(text || '').replace(/\s+/gu, '');
  116. for (const artifact of raw.artifact.filter(a => a.report)) {
  117. await go('/report/' + encodeURIComponent(artifact.objectId), 'view-report [data-chapter]');
  118. const text = await page.locator('view-report').evaluate(el => el.renderRoot.textContent);
  119. const expected = [artifact.report.title || artifact.title, ...(artifact.report.chapters || []).map(c => c.title)].filter(Boolean);
  120. const missing = expected.filter(title => !normalize(text).includes(normalize(title)));
  121. const cellValues = cell => {
  122. if (Array.isArray(cell)) return cell.flatMap(item => Array.isArray(item) ? [String(item[0]), String(item[1]) + '%'] : [String(item)]);
  123. if (typeof cell === 'number') return [cell.toLocaleString('zh-CN')];
  124. if (typeof cell === 'string' && artifact.report.evidence?.[cell]) return [artifact.report.evidence[cell].claim || cell];
  125. return [String(cell ?? '—')];
  126. };
  127. const bodyValues = (artifact.report.chapters || []).flatMap(c => c.sections || []).flatMap(s => {
  128. if (['para', 'longpara'].includes(s.kind)) return [s.text || ''];
  129. if (['table', 'matrix'].includes(s.kind)) return [...(s.data?.cols || []), ...(s.data?.rows || []).flatMap(row => row.flatMap(cellValues))];
  130. return [];
  131. }).filter(value => normalize(value));
  132. const missingBodyValues = bodyValues.filter(value => !normalize(text).includes(normalize(value)));
  133. checks.push({ kind: 'report-read', artifactId: artifact.objectId, chapterCount: expected.length - 1, missingTitles: missing,
  134. paragraphAndTableValuesChecked: bodyValues.length, missingBodyValueCount: missingBodyValues.length,
  135. passed: missing.length === 0 && missingBodyValues.length === 0 });
  136. }
  137. for (const artifact of raw.artifact.filter(a => !a.report && a.rawText)) {
  138. await go('/reports?artifact=' + encodeURIComponent(artifact.objectId), 'kf-evidence-chain .raw');
  139. const text = await page.locator('kf-evidence-chain .raw').textContent();
  140. checks.push({ kind: 'raw-text-read', artifactId: artifact.objectId, passed: normalize(text) === normalize(artifact.rawText) });
  141. }
  142. await context.close();
  143. } finally { await browser.close(); }
  144. audit.browser = { checks, errors, functionCalls, blockedActions, businessWrites: 0 };
  145. }
  146. await save(path.join(out, 'audit.json'), audit);
  147. console.log(JSON.stringify({ artifactCount: raw.artifact.length, allArtifactsInReportCenter: true,
  148. groups: audit.groups, browserChecks: audit.browser?.checks.length,
  149. browserFailureCount: audit.browser?.checks.filter(c => c.passed === false || c.noPageOverflow === false).length,
  150. browserErrors: audit.browser?.errors, blockedActions: audit.browser?.blockedActions,
  151. acceptancePassed: audit.productionDisplayAcceptancePassed }, null, 2));