День 19 · Строки, руны и файлы · страница 4 из 6

Строка выгрузки: поля и ошибки

Выгрузка заказов — строки вида TK-101;Болт М8;12;1500: артикул, название, количество в штуках и цена в копейках. Часть строк битая: поле потерялось, в количестве 12шт, в цене 9,90. Здесь строка режется на поля, а parseLine возвращает значения или ошибку.


Разрезать и почистить

Ситуация. Из строки нужны отдельные поля, а вокруг названия стоят лишние пробелы.

Код для чтения · разбираем, набирать не нужно
package main

import (
	"fmt"
	"strings"
)

func main() {
	line := "TK-101; Болт М8 ;12;1500"
	parts := strings.Split(line, ";")
	fmt.Println(len(parts))
	fmt.Printf("[%s]\n", parts[1])
	fmt.Printf("[%s]\n", strings.TrimSpace(parts[1]))
	fmt.Println(strings.Fields("  Болт   М8  "))
	fmt.Println(strings.ToLower("БОЛТ М8"))
	fmt.Println(strings.Contains("Болт М8", "М8"))
	fmt.Println(strings.HasPrefix("TK-101", "TK-"))
	fmt.Println(strings.Join([]string{"Болт", "М8"}, " / "))
}

Что увидите.

Вывод · это печатает программа, набирать не нужно
4
[ Болт М8 ]
[Болт М8]
[Болт М8]
болт м8
true
true
Болт / М8

Как это читать. Квадратные скобки вокруг %s поставлены, чтобы увидеть пробелы по краям.

Функция Что делает В примере
strings.Split(s, ";") режет строку по разделителю и возвращает срез строк; разделитель пропадает. Частей на одну больше, чем разделителей 4 части
strings.TrimSpace(s) убирает пробелы, табуляции и переводы строки по краям, внутри не трогает [ Болт М8 ] → [Болт М8]
strings.Fields(s) режет по пробелам любой длины; пустых частей не бывает срез из двух строк в квадратных скобках
strings.ToLower(s) все буквы маленькие, русские тоже болт м8
strings.Contains(s, part) есть ли кусок внутри строки true
strings.HasPrefix(s, start) начинается ли строка с куска true
strings.Join(parts, sep) склеивает срез строк через разделитель Болт / М8

Функции strings понимают UTF-8: ToLower знает русские буквы, Split не разрежет букву. Кириллицу ломает только наша арифметика с байтами.


parseLine: значения или ошибка

Ситуация. Каждую строку выгрузки нужно превратить в название, количество и цену — или понять, что строка битая, и сказать чем.

Код для чтения · разбираем, набирать не нужно
package main

import (
	"errors"
	"fmt"
	"strconv"
	"strings"
)

func parseLine(line string) (string, int, int, error) {
	if strings.TrimSpace(line) == "" {
		return "", 0, 0, errors.New("пустая строка")
	}
	parts := strings.Split(line, ";")
	if len(parts) != 4 {
		return "", 0, 0, fmt.Errorf("нужно 4 поля, а их %d", len(parts))
	}
	name := strings.TrimSpace(parts[1])
	qty, err := strconv.Atoi(strings.TrimSpace(parts[2]))
	if err != nil {
		return "", 0, 0, fmt.Errorf("количество: %v", err)
	}
	price, err := strconv.Atoi(strings.TrimSpace(parts[3]))
	if err != nil {
		return "", 0, 0, fmt.Errorf("цена: %v", err)
	}
	return name, qty, price, nil
}

func main() {
	fmt.Println(parseLine("TK-101; Болт М8 ;12;1500"))
	fmt.Println(parseLine("   "))
	fmt.Println(parseLine("TK-102;Шайба;100"))
	fmt.Println(parseLine("TK-103;Гайка М10;12шт;350"))
	fmt.Println(parseLine("TK-104;Скоба;5;9,90"))
}

Что увидите.

Вывод · это печатает программа, набирать не нужно
Болт М8 12 1500 <nil>
 0 0 пустая строка
 0 0 нужно 4 поля, а их 3
 0 0 количество: strconv.Atoi: parsing "12шт": invalid syntax
 0 0 цена: strconv.Atoi: parsing "9,90": invalid syntax

Как это читать. Четыре результата, ошибка — последним, при ошибке остальные нулевые — правило дня 12. У битых строк вывод начинается с пробела: название пустое.

Порядок проверок важен:

  1. Пустая строка — первой, иначе сообщение было бы «нужно 4 поля, а их 1»: Split пустой строки даёт одну пустую часть.
  2. Число полей — до parts[3], иначе строка из трёх полей роняет программу паникой — «Что может пойти не так».
  3. Каждое поле — через TrimSpace, иначе " 12" не станет числом.
  4. Текст ошибки Atoi сохраняется: %v вставляет его целиком.

Попробуйте сейчас: строка на поля.

Цель: parseLine разбирает верную строку и называет, что не так с битыми.

1. Наберите программу из раздела выше и запустите:

✎ Наберите в файл scratch/main.go
package main

import (
	"errors"
	"fmt"
	"strconv"
	"strings"
)

func parseLine(line string) (string, int, int, error) {
	if strings.TrimSpace(line) == "" {
		return "", 0, 0, errors.New("пустая строка")
	}
	parts := strings.Split(line, ";")
	if len(parts) != 4 {
		return "", 0, 0, fmt.Errorf("нужно 4 поля, а их %d", len(parts))
	}
	name := strings.TrimSpace(parts[1])
	qty, err := strconv.Atoi(strings.TrimSpace(parts[2]))
	if err != nil {
		return "", 0, 0, fmt.Errorf("количество: %v", err)
	}
	price, err := strconv.Atoi(strings.TrimSpace(parts[3]))
	if err != nil {
		return "", 0, 0, fmt.Errorf("цена: %v", err)
	}
	return name, qty, price, nil
}

func main() {
	fmt.Println(parseLine("TK-101; Болт М8 ;12;1500"))
	fmt.Println(parseLine("   "))
	fmt.Println(parseLine("TK-102;Шайба;100"))
	fmt.Println(parseLine("TK-103;Гайка М10;12шт;350"))
	fmt.Println(parseLine("TK-104;Скоба;5;9,90"))
}
▶ Выполните
cd ~/gocourse/day19/scratch
go run .

2. Допишите в main свой случай — например, пустое количество: "TK-105;Скоба;;90". Запустите и прочитайте сообщение.

Готово, когда: пять строк вывода совпали с выводом выше, а на ваш случай пришла понятная ошибка. Проверка дня опыт не сверяет.


Тест к двум функциям

parseLine и fitName со страницы 3 нужны отчёту. Они поедут в файл line.go каталога report, тест — рядом, в line_test.go, пакет у всех файлов main.

Табличный тест теперь читается целиком. cases := []struct{…}{…} — срез из дня 16; каждый элемент — запись с полями in, width, want (struct разберём в блоке 5). Новый случай — ещё одна строка {…},, цикл не меняется. Ниже — по одному случаю на функцию:

Код для чтения · разбираем, набирать не нужно
package main

import "testing"

func TestFitName(t *testing.T) {
	cases := []struct {
		in    string
		width int
		want  string
	}{
		{"Bolt-M8", 10, "Bolt-M8..."},
	}
	for _, c := range cases {
		got := fitName(c.in, c.width)
		if got != c.want {
			t.Errorf("fitName(%q, %d) = %q, хотели %q", c.in, c.width, got, c.want)
		}
	}
}

func TestParseLineGood(t *testing.T) {
	name, qty, price, err := parseLine("TK-101; Болт М8 ;12;1500")
	if err != nil {
		t.Fatalf("верная строка, а ошибка: %v", err)
	}
	if name != "Болт М8" || qty != 12 || price != 1500 {
		t.Errorf("parseLine = %q, %d, %d", name, qty, price)
	}
}

%q печатает строку в кавычках — так видны точки и пробелы на краях. t.Fatalf останавливает тест сразу: при ошибке сравнивать поля незачем.

Что увидите.

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day19/report$ go test -v .
=== RUN   TestFitName
--- PASS: TestFitName (0.00s)
=== RUN   TestParseLineGood
--- PASS: TestParseLineGood (0.00s)
PASS
ok  	day19/report	0.005s

Одного случая мало. Тот же тест на неверных функциях: fitName считает длину через len и режет name[:width-1], как fix1 до починки, а parseLine на строке с неверным числом полей возвращает nil:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day19/report$ go test .
ok  	day19/report	0.005s

У Bolt-M8 длина в байтах и в буквах одна, а в верной строке четыре поля — ошибок на этих случаях не видно. Проверка дня подменяет line.go неверными версиями и ждёт, что ваш тест упадёт. Какой случай что ловит:

Ошибка в функции Случай, на котором она видна
точки дописываются по числу байтов русское название короче ширины: точек меньше
длинное название режется по байтам русское название длиннее ширины: последняя буква разрезана, %q покажет её обрубок вроде \xd1
строка с неверным числом полей проходит строка из трёх или пяти полей, а в тесте t.Errorf, если err == nil

Ожидаемое want считайте руками, в буквах: тест, списавший ответ у функции, повторит её ошибку.


Попробуйте сейчас: line.go и тест к нему.

Цель: в ~/gocourse/day19/report лежат line.go с двумя функциями и line_test.go с вашими случаями; тест зелёный и падает на неверных версиях функций.

1. Прочитайте условие; ширина колонки в line.go не нужна — её передают параметром.

▶ Выполните
cd ~/gocourse/day19/report
cat TASK.txt

2. Наберите line.go — только эти две функции:

✎ Наберите в файл report/line.go
package main

import (
	"errors"
	"fmt"
	"strconv"
	"strings"
	"unicode/utf8"
)

func parseLine(line string) (string, int, int, error) {
	if strings.TrimSpace(line) == "" {
		return "", 0, 0, errors.New("пустая строка")
	}
	parts := strings.Split(line, ";")
	if len(parts) != 4 {
		return "", 0, 0, fmt.Errorf("нужно 4 поля, а их %d", len(parts))
	}
	name := strings.TrimSpace(parts[1])
	qty, err := strconv.Atoi(strings.TrimSpace(parts[2]))
	if err != nil {
		return "", 0, 0, fmt.Errorf("количество: %v", err)
	}
	price, err := strconv.Atoi(strings.TrimSpace(parts[3]))
	if err != nil {
		return "", 0, 0, fmt.Errorf("цена: %v", err)
	}
	return name, qty, price, nil
}

func fitName(name string, width int) string {
	n := utf8.RuneCountInString(name)
	if n > width {
		runes := []rune(name)
		return string(runes[:width-1]) + "~"
	}
	return name + strings.Repeat(".", width-n)
}

3. Наберите заготовку теста из раздела выше — по одному случаю на функцию:

✎ Наберите в файл report/line_test.go
package main

import "testing"

func TestFitName(t *testing.T) {
	cases := []struct {
		in    string
		width int
		want  string
	}{
		{"Bolt-M8", 10, "Bolt-M8..."},
	}
	for _, c := range cases {
		got := fitName(c.in, c.width)
		if got != c.want {
			t.Errorf("fitName(%q, %d) = %q, хотели %q", c.in, c.width, got, c.want)
		}
	}
}

func TestParseLineGood(t *testing.T) {
	name, qty, price, err := parseLine("TK-101; Болт М8 ;12;1500")
	if err != nil {
		t.Fatalf("верная строка, а ошибка: %v", err)
	}
	if name != "Болт М8" || qty != 12 || price != 1500 {
		t.Errorf("parseLine = %q, %d, %d", name, qty, price)
	}
}
▶ Выполните
go test -v .

4. Допишите случаи сами — названия, ширину и строки выбирайте свои:

  • в таблицу TestFitName — русское название короче ширины и русское название длиннее ширины;
  • новой функцией TestParseLine… — строку, где полей не четыре: err обязан быть не nil.
▶ Выполните
go test -v .

5. Проверьте силу теста приёмом из дня 14: испортите line.go, запустите go test ., верните как было. Порча первая — в fitName две строки внутри if замените одной return name[:width-1] + "~": упасть обязан случай с длинным русским названием. Порча вторая — в parseLine вместо fmt.Errorf("нужно 4 поля, а их %d", len(parts)) поставьте nil: упасть обязан случай с неверным числом полей.

6. Закоммитьте:

▶ Выполните
cd ~/gocourse/day19
git add .
git commit -m "report: parseLine, fitName и тест"

Готово, когда: в тесте есть русские названия короче и длиннее ширины и строка с неверным числом полей; go test . печатает ok, на каждой порче — FAIL; коммит в git log --oneline (пункты testfit и testparse).


Что может пойти не так

В программе из раздела parseLine забыли три строки проверки числа полей — на строке TK-102;Шайба;100 нет parts[3]:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day19/scratch$ go run .
Болт М8 12 1500 <nil>
 0 0 пустая строка
panic: runtime error: index out of range [3] with length 3

goroutine 1 [running]:
main.parseLine({0x4a2d30, 0x15})
	/home/stagiaire/gocourse/day19/scratch/main.go:20 +0x258
main.main()
	/home/stagiaire/gocourse/day19/scratch/main.go:30 +0x20a
exit status 2

Стек читается снизу вверх: main на строке 30 вызвал parseLine, а она упала на строке 20.

Функция объявлена и в line.go, и в main.go — два сообщения на одну ошибку:

Пример · только посмотреть, набирать не нужно
stagiaire@lab:~/gocourse/day19/report$ go run .
# day19/report
./main.go:5:6: fitName redeclared in this block
	./line.go:31:6: other declaration of fitName
Что видите Что это значит Что делать
panic: runtime error: index out of range [3] with length 3 взяли поле, которого нет: полей после Split три проверять len(parts) до обращения к полям
fitName redeclared in this block и other declaration of fitName одна функция объявлена в двух файлах пакета оставить её только в line.go
runtime.main_main·f: function main is undeclared in the main package go run . в report, где ещё нет main.go. go test . при этом работает программу пишем на шестой странице, до неё — только go test .
нужно 4 поля, а их 1 на пустой строке проверка пустой строки стоит после Split пустую строку проверять первой
strconv.Atoi: parsing " 12": invalid syntax поле не очищено от пробелов strings.TrimSpace перед Atoi
проверка: «Тест проходит и на заведомо неверной программе» в тесте нет случая, на котором видна ошибка подмены подсказка под пунктом называет случай; таблица «Одного случая мало»

Дальше: как прочитать выгрузку из файла по имени — course next