feat(B05 평면 구조물 마커): 구조물 측점 마커 · 고름 정보 창 · 20 m 법선 · 끌기 · 방향키 1 m (399)
- 마커 = 종단 알약과 같은 목록(laneStructures) · 노선 위 알약 꼴(종류 색) · 스냅 · 간격과 무관 - 고름 = 알약 고름과 같은 길(onStructureSelect · 공용 고름) · 알약 벌룬 그대로 · 좌우 20 m 법선 - 끌기 · 방향키 = 노선 따라 정수 m 1 m 씩 · 시점 · 종점 밖 안 감 · 놓을 때 알약 끌기와 같은 길로 한 번 - 집기 차례 = 교각점 · 곡선 손잡이 다음 · 옛 마름모 지움 - 시험 test_399_plan_structure_marks · test_391 마름모 줄 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
/* 399 B05 평면 구조물 측점 마커(요구 250-2) — `_RouteEdit_StructureMarks` 를 Node 로 돌려 JSON 한 줄
|
||||
* (`test_399_plan_structure_marks.py` 가 판정). 알약 목록 · 벌룬 · 고름 · 가게는 가짜 · 노선 셈 · 법선 ·
|
||||
* 걸음 · 방향키 부호는 진짜(`_Input` · `_Hover` · `logic/_Frame` · `logic/_KeyNudge` · `logic/_StructureMarks`).
|
||||
* node helper_399_plan_structure_marks.cjs
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
|
||||
const out = {};
|
||||
let selection = null;
|
||||
const laneInputs = [];
|
||||
const balloonsMade = [];
|
||||
|
||||
/* 가짜 DOM — 벌룬 요소 · 창 열쇠 듣기 */
|
||||
function fakeElement() {
|
||||
const element = {
|
||||
style: {},
|
||||
classList: { add: (...names) => element.classes.push(...names) },
|
||||
classes: [],
|
||||
offsetHeight: 30,
|
||||
removed: false,
|
||||
remove: () => (element.removed = true),
|
||||
replaceWith: (next) => ((element.removed = true), parent.children.push(next)),
|
||||
};
|
||||
return element;
|
||||
}
|
||||
const parent = { children: [], append: (child) => parent.children.push(child) };
|
||||
const listeners = {};
|
||||
global.window = {
|
||||
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
|
||||
removeEventListener: () => {},
|
||||
setTimeout: () => 0,
|
||||
setInterval: () => 0,
|
||||
clearTimeout: () => {},
|
||||
clearInterval: () => {},
|
||||
};
|
||||
const key = (type, name) =>
|
||||
(listeners[type] || []).forEach((fn) =>
|
||||
fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }),
|
||||
);
|
||||
|
||||
const STUBS = {
|
||||
"../A00_Common/touch_bridge": {
|
||||
pickPx: (v) => v,
|
||||
isPinchWheel: () => false,
|
||||
wheelZoomsIn: () => true,
|
||||
},
|
||||
"../A00_Common/input_prefs": { isTrackpadPinch: () => false },
|
||||
"../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 },
|
||||
"../A00_Common/design/design_store": { draftProjectId: () => "p1" },
|
||||
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection },
|
||||
"../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true },
|
||||
"./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 },
|
||||
"./B05_Profile_Api_Structures": {
|
||||
structureAnchorM: (s) => s.chainage_m ?? s.start_m ?? 0,
|
||||
},
|
||||
"./B05_Profile_UI_Structures_Marks": {
|
||||
laneStructures: (list) => (laneInputs.push(list), list),
|
||||
structurePillTexts: (list) =>
|
||||
new Map(list.map((s) => [s.structure_id, { name: `이름 ${s.type_id}`, pill: s.type_id }])),
|
||||
positionText: (s) => `pos ${s.chainage_m}`,
|
||||
balloonElement: (lines, type) => {
|
||||
const element = fakeElement();
|
||||
element.lines = lines;
|
||||
element.color = type?.style?.color;
|
||||
balloonsMade.push(element);
|
||||
return element;
|
||||
},
|
||||
},
|
||||
"./logic/B05_Profile_UI_Structures_Balloon": {
|
||||
balloonLines: (structure, type, head) => [
|
||||
`${head.name} ${head.position}`,
|
||||
`옵션 ${type?.name}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
const cache = new Map();
|
||||
function loadTs(full) {
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
/** 그린 것만 적는 가짜 캔버스 — 획 = 색 · 굵기 · 그때 옮긴 자리(translate) · 선 점. */
|
||||
function fakeContext(canvas) {
|
||||
const log = [];
|
||||
let at = [0, 0];
|
||||
let points = [];
|
||||
const ctx = {
|
||||
canvas,
|
||||
save() {},
|
||||
restore: () => (at = [0, 0]),
|
||||
setLineDash() {},
|
||||
translate: (x, y) => (at = [at[0] + x, at[1] + y]),
|
||||
rotate() {},
|
||||
beginPath: () => (points = []),
|
||||
moveTo: (x, y) => points.push([x, y]),
|
||||
lineTo: (x, y) => points.push([x, y]),
|
||||
arc() {},
|
||||
fill() {},
|
||||
stroke: () => log.push({ color: ctx.strokeStyle, width: ctx.lineWidth, at: [...at], points }),
|
||||
};
|
||||
return { ctx, log };
|
||||
}
|
||||
|
||||
const marks = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_StructureMarks.ts"));
|
||||
const logic = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StructureMarks.ts"));
|
||||
|
||||
// 노선 = 동서 200 m · 화면 2 px/m(위 = 북) · 구조물 A 40 m(색 #e11d48) · 관 80.17 m(옛 소수)
|
||||
const EAST = [
|
||||
[0, 0],
|
||||
[200, 0],
|
||||
];
|
||||
const toScreen = ([x, y]) => [x * 2, -y * 2];
|
||||
const toMetric = (px, py) => [px / 2, -py / 2];
|
||||
const structures = [
|
||||
{ structure_id: "A", type_id: "box", chainage_m: 40, start_m: null, end_m: null },
|
||||
{ structure_id: "pipe-80.17", type_id: "pipe", chainage_m: 80.17, start_m: null, end_m: null },
|
||||
];
|
||||
const types = [
|
||||
{ type_id: "box", name: "BOX", style: { color: "#e11d48" } },
|
||||
{ type_id: "pipe", name: "배수관", style: { color: "#2563eb" } },
|
||||
];
|
||||
const selects = [];
|
||||
const moves = [];
|
||||
let redrawn = 0;
|
||||
marks.setPlanMarkSource("p1", {
|
||||
marks: () => structures,
|
||||
types: () => types,
|
||||
intervalM: () => 20,
|
||||
select: (id) => selects.push(id),
|
||||
move: (id, to) => moves.push([id, to]),
|
||||
});
|
||||
|
||||
const canvas = { isConnected: true, parentElement: parent };
|
||||
let line = EAST;
|
||||
let picked = -1;
|
||||
const status = { textContent: "" };
|
||||
const scene = () => ({ toScreen, view: { width: 400, height: 300 }, rotationRad: 0 });
|
||||
const style = { getPropertyValue: () => "" };
|
||||
const draw = () => {
|
||||
const fake = fakeContext(canvas);
|
||||
marks.drawStructureMarks(fake.ctx, scene(), line, style);
|
||||
return fake.log;
|
||||
};
|
||||
const input = marks.bindStructureMarks(
|
||||
{
|
||||
canvas,
|
||||
status,
|
||||
stationIntervalM: 20,
|
||||
state: () => ({ planned: line, plannedLine: line, picked }),
|
||||
toScreen,
|
||||
toMetric,
|
||||
unrotate: (x, y) => [x, y],
|
||||
draw: () => (redrawn += 1),
|
||||
routeHead: () => "노선",
|
||||
measureMode: () => false,
|
||||
},
|
||||
() => false,
|
||||
);
|
||||
|
||||
// ① 그리기 — 알약 목록 그대로 · 종류 색 · 고름 없으면 법선 · 창 없음
|
||||
const plain = draw();
|
||||
out.laneGotSource = laneInputs[laneInputs.length - 1] === structures;
|
||||
const fills = (log) => log.filter((s) => s.width === 8).map((s) => ({ color: s.color, at: s.at }));
|
||||
out.plain = { marks: fills(plain), normals: plain.filter((s) => s.color === "#0ea5e9").length };
|
||||
out.balloonsAfterPlain = parent.children.filter((c) => !c.removed).length;
|
||||
|
||||
// ② 공용 고름 = 관(누가거리만 · 382-2) → 그 마커 하늘 테 · 법선 · 창(알약 벌룬 줄)
|
||||
selection = { chainageM: 80.17, structureId: null };
|
||||
const pickedPipe = draw();
|
||||
const ring = pickedPipe.filter((s) => s.color === "#0ea5e9" && s.width === 15);
|
||||
const normal = pickedPipe.find((s) => s.color === "#0ea5e9" && s.width === 2.4);
|
||||
const shown = parent.children.filter((c) => !c.removed);
|
||||
out.pickedPipe = {
|
||||
ringAt: ring.map((s) => s.at),
|
||||
normal: normal && normal.points,
|
||||
balloon: shown.length === 1 && {
|
||||
lines: shown[0].lines,
|
||||
left: shown[0].style.left,
|
||||
top: shown[0].style.top,
|
||||
color: shown[0].color,
|
||||
},
|
||||
};
|
||||
selection = { chainageM: 40, structureId: "A" };
|
||||
draw();
|
||||
const shownA = parent.children.filter((c) => !c.removed);
|
||||
out.pickedStructureBalloon = shownA.length === 1 && shownA[0].lines;
|
||||
selection = null;
|
||||
draw();
|
||||
out.balloonsAfterRelease = parent.children.filter((c) => !c.removed).length;
|
||||
|
||||
// ③ 끌기 — A(화면 80, 0) 누름 = 고름 · 3 m 끌기(86 px) = 43 · 놓을 때 한 번
|
||||
out.pressMiss = input.press(300, 100);
|
||||
out.pressA = input.press(80, 3);
|
||||
out.selects = [...selects];
|
||||
input.move(83, 10); // 41.5 → 42
|
||||
input.move(86, -20); // 43
|
||||
out.dragStatus = status.textContent;
|
||||
out.movesBeforeRelease = moves.length;
|
||||
input.release();
|
||||
out.dragMove = moves[moves.length - 1];
|
||||
out.dragDoneStatus = status.textContent;
|
||||
// 노선 밖 — 끝(200) · 시점(0)에 붙음
|
||||
input.press(80, 0);
|
||||
input.move(9999, 0);
|
||||
input.release();
|
||||
out.pastEnd = moves[moves.length - 1];
|
||||
input.press(80, 0);
|
||||
input.move(-500, 0);
|
||||
input.release();
|
||||
out.beforeStart = moves[moves.length - 1];
|
||||
// 옛 소수 관(80.17) — 눌렀다 반올림 자리에서 떼면 안 옮김 · 1 m 끌면 정수
|
||||
const count = moves.length;
|
||||
input.press(160.34, 0);
|
||||
input.move(160.5, 1);
|
||||
input.release();
|
||||
out.oldDecimalStay = moves.length === count;
|
||||
input.press(160.34, 0);
|
||||
input.move(162.2, 0);
|
||||
input.release();
|
||||
out.oldDecimalMoved = moves[moves.length - 1];
|
||||
|
||||
// ④ 방향키 — 고른 마커(A) · 교각점 안 고름 = 노선 따라 1 m · 뗄 때 한 번
|
||||
selection = { chainageM: 40, structureId: "A" };
|
||||
key("keydown", "ArrowRight");
|
||||
key("keyup", "ArrowRight");
|
||||
out.keyRight = moves[moves.length - 1];
|
||||
key("keydown", "ArrowLeft");
|
||||
key("keyup", "ArrowLeft");
|
||||
out.keyLeft = moves[moves.length - 1];
|
||||
key("keydown", "ArrowUp"); // 노선과 직각 = → · ↑ 는 +
|
||||
key("keyup", "ArrowUp");
|
||||
out.keyUpPerpendicular = moves[moves.length - 1];
|
||||
// 서→동이 아니라 동→서 노선 — 오른쪽(동) = 뒤로
|
||||
line = [
|
||||
[200, 0],
|
||||
[0, 0],
|
||||
];
|
||||
key("keydown", "ArrowRight");
|
||||
key("keyup", "ArrowRight");
|
||||
out.keyRightWestward = moves[moves.length - 1];
|
||||
line = EAST;
|
||||
// 교각점 고름 = 교각점 방향키 몫(마커 안 옮김)
|
||||
picked = 2;
|
||||
const before = moves.length;
|
||||
key("keydown", "ArrowRight");
|
||||
key("keyup", "ArrowRight");
|
||||
out.keyWithNodePicked = moves.length - before;
|
||||
picked = -1;
|
||||
// 고름 없음 = 안 옮김
|
||||
selection = null;
|
||||
key("keydown", "ArrowRight");
|
||||
key("keyup", "ArrowRight");
|
||||
out.keyNoPick = moves.length - before;
|
||||
|
||||
// ⑤ 목록 바뀜 알림 = 그 편집 창 다시 그림
|
||||
const was = redrawn;
|
||||
marks.planMarksChanged("p1");
|
||||
out.redrawnOnChange = redrawn - was;
|
||||
|
||||
// ⑥ 순수 셈
|
||||
out.logic = {
|
||||
round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)],
|
||||
clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)],
|
||||
step: [logic.markStep(80.17, 1, 200), logic.markStep(0, -1, 200), logic.markStep(199, 1, 199.6)],
|
||||
pickId: logic.pickedMarkId(
|
||||
[{ id: "A", chainageM: 40 }],
|
||||
{ chainageM: 1, structureId: "A" },
|
||||
0.51,
|
||||
),
|
||||
pickNear: logic.pickedMarkId(
|
||||
[{ id: "A", chainageM: 40 }],
|
||||
{ chainageM: 40.3, structureId: null },
|
||||
0.51,
|
||||
),
|
||||
pickFar: logic.pickedMarkId(
|
||||
[{ id: "A", chainageM: 40 }],
|
||||
{ chainageM: 41, structureId: null },
|
||||
0.51,
|
||||
),
|
||||
pickOther: logic.pickedMarkId(
|
||||
[{ id: "A", chainageM: 40 }],
|
||||
{ chainageM: 40, structureId: "Z" },
|
||||
0.51,
|
||||
),
|
||||
};
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -46,7 +46,8 @@ def test_맴돔과_고름이_한_함수():
|
||||
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
|
||||
assert "drawStationNormal(context, scene, line, watch.chainageM);" in render
|
||||
assert "leftCut" not in render and "--b05-footprint-cut" not in render
|
||||
assert 'context.strokeStyle = "#ff9800";' in render # 구조물 마름모 그대로
|
||||
assert 'context.strokeStyle = "#ff9800";' not in render # 구조물 고름 = 마커 법선 · 창(399)
|
||||
assert "drawStructureMarks(context, scene, line, style);" in render
|
||||
|
||||
|
||||
def test_MC_는_호_가운데(results):
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""399 B05 평면 구조물 측점 마커(요구 250-2) — 알약과 같은 목록 · 고름 = 공용 고름 · 정보 창(알약 벌룬) ·
|
||||
20 m 법선 · 끌기 · 방향키 = 노선 따라 정수 m 1 m 씩 · 시점 · 종점 밖 안 감 · 놓을 때 한 번 옮김(알약과 같은 길).
|
||||
|
||||
TS 셈은 `helper_399_plan_structure_marks.cjs`(Node · 노선 셈 · 법선 · 걸음은 진짜).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_399_plan_structure_marks.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs")
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def test_마커_목록은_알약_목록(got) -> None:
|
||||
assert got["laneGotSource"] is True # 알약 레인과 같은 `laneStructures`
|
||||
assert got["plain"]["marks"] == [
|
||||
{"color": "#e11d48", "at": [80, 0]}, # 40 m × 2 px · 종류 색
|
||||
{"color": "#2563eb", "at": [160.34, 0]},
|
||||
]
|
||||
assert got["plain"]["normals"] == 0 and got["balloonsAfterPlain"] == 0
|
||||
lane = _read("B05_Profile", "B05_Profile_UI_Structures_Marks.ts")
|
||||
assert "const structures = laneStructures(options.structures);" in lane
|
||||
marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts")
|
||||
assert "laneStructures(source.marks())" in marks
|
||||
bridge = _read("B05_Profile", "B05_Profile_UI_Page_Structures.ts")
|
||||
assert "setStructures([...ownStructures, ...pipeMarks]);" in bridge
|
||||
assert "marks: (): StructureInstance[] => [...ownStructures, ...pipeMarks]," in bridge
|
||||
assert "planMarksChanged(deps.projectId);" in bridge
|
||||
|
||||
|
||||
def test_고름_정보_창_법선(got) -> None:
|
||||
pipe = got["pickedPipe"]
|
||||
assert pipe["ringAt"] == [[160.34, 0]] # 관 고름(누가거리만)도 그 마커
|
||||
assert pipe["normal"] == [[160.34, -40], [160.34, 40]] # 좌우 20 m
|
||||
assert pipe["balloon"]["lines"] == ["이름 pipe pos 80.17", "옵션 배수관"] # 알약 벌룬 줄
|
||||
assert pipe["balloon"]["color"] == "#2563eb"
|
||||
assert got["pickedStructureBalloon"] == ["이름 box pos 40", "옵션 BOX"]
|
||||
assert got["balloonsAfterRelease"] == 0
|
||||
|
||||
|
||||
def test_공용_고름과_같은_길() -> None:
|
||||
view = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수
|
||||
assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view
|
||||
render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts")
|
||||
labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks")
|
||||
nodes = render.index("scene.planned.forEach((vertex, index) => {")
|
||||
assert labels < nodes # 라벨 위 · 교각점 아래
|
||||
|
||||
|
||||
def test_집기_차례는_교각점_손잡이_다음() -> None:
|
||||
pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts")
|
||||
node = pointer.index("if (hitNode >= 0) {")
|
||||
handle = pointer.index("} else if (dragHandle) {")
|
||||
mark = pointer.index("} else if (marks.press(px, py)) {")
|
||||
tick = pointer.index('if (!pick || !("chainage" in pick)) return false;')
|
||||
assert node < handle < mark < tick
|
||||
|
||||
|
||||
def test_끌기_1m_정수_놓을_때_한_번(got) -> None:
|
||||
assert got["pressMiss"] is False and got["pressA"] is True
|
||||
assert got["selects"] == ["A"]
|
||||
assert got["movesBeforeRelease"] == 0 # 끄는 동안은 미리보기만
|
||||
assert got["dragMove"] == ["A", 43] # 3 m
|
||||
assert "2+0 → 2+3(+3 m) 옮김" in got["dragDoneStatus"]
|
||||
assert got["oldDecimalStay"] is True # 옛 소수 자리 눌렀다 떼기 = 안 옮김
|
||||
assert got["oldDecimalMoved"] == ["pipe-80.17", 81]
|
||||
|
||||
|
||||
def test_노선_밖_안_감(got) -> None:
|
||||
assert got["pastEnd"] == ["A", 200]
|
||||
assert got["beforeStart"] == ["A", 0]
|
||||
assert got["logic"]["clamp"] == [0, 199] # 종점 199.6 → 199(정수 m 안쪽)
|
||||
assert got["logic"]["step"] == [81, 0, 199]
|
||||
|
||||
|
||||
def test_방향키_노선_따라_1m(got) -> None:
|
||||
assert got["keyRight"] == ["A", 41]
|
||||
assert got["keyLeft"] == ["A", 39]
|
||||
assert got["keyUpPerpendicular"] == ["A", 41] # 직각 = → · ↑ 가 +
|
||||
assert got["keyRightWestward"] == ["A", 39] # 노선이 서쪽으로 가면 → = 뒤로
|
||||
assert got["keyWithNodePicked"] == 0 # 교각점 고름 = 교각점 방향키 몫
|
||||
assert got["keyNoPick"] == 0
|
||||
|
||||
|
||||
def test_고른_마커_찾기(got) -> None:
|
||||
logic = got["logic"]
|
||||
assert logic["round"] == [41, 42]
|
||||
assert (logic["pickId"], logic["pickNear"], logic["pickFar"], logic["pickOther"]) == (
|
||||
"A",
|
||||
"A",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert got["redrawnOnChange"] == 1
|
||||
|
||||
|
||||
def test_편집기_줄_수_그대로() -> None:
|
||||
edit = _read("B05_Profile", "B05_Profile_UI_RouteEdit.ts")
|
||||
assert edit.count("\n") <= 695
|
||||
for name in (
|
||||
"B05_Profile_UI_RouteEdit_StructureMarks.ts",
|
||||
"B05_Profile_UI_RouteEdit_Render.ts",
|
||||
"B05_Profile_UI_RouteEdit_Pointer.ts",
|
||||
):
|
||||
assert _read("B05_Profile", name).count("\n") <= 700
|
||||
Reference in New Issue
Block a user